lingshu-tools 0.10.0

Tool registry, ToolHandler trait, and 50+ tool implementations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
//! Lightweight config reference for tool context.
//!
//! WHY a separate type: lingshu-core owns AppConfig but lingshu-tools
//! can't depend on lingshu-core (that would create a cycle). Instead,
//! we define a minimal config view here that lingshu-core populates.

use std::collections::HashMap;
use std::path::PathBuf;

use crate::execution_tmp::shared_tmp_dir;
use crate::tools::backends::{
    BackendKind, DaytonaBackendConfig, DockerBackendConfig, ModalBackendConfig,
    SingularityBackendConfig, SshBackendConfig,
};
use lingshu_security::path_policy::PathPolicy;

/// Per-backend settings under `web_search.backends.<name>` in config.yaml.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct WebSearchBackendConfigRef {
    pub api_key: Option<String>,
    pub endpoint: Option<String>,
    pub rps: Option<f64>,
    pub timeout_secs: Option<u64>,
}

/// Hermes-aligned `web:` section — per-capability backend overrides.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct WebToolsConfigRef {
    /// Override for `web_search` when non-empty (Hermes `web.search_backend`).
    pub search_backend: String,
    /// Default backend for `web_extract` / `web_crawl` (Hermes `web.extract_backend`).
    pub extract_backend: String,
    /// Shared fallback for both capabilities (Hermes `web.backend`).
    pub backend: String,
}

/// Web search chain configuration (`web_search` section in config.yaml).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct WebSearchConfigRef {
    /// Primary backend name (`searxng`, `brave`, `ddgs`, …).
    pub primary: String,
    /// Ordered fallback backends when primary fails transiently.
    pub fallbacks: Vec<String>,
    /// Default request timeout in seconds.
    pub timeout_secs: u64,
    /// Per-backend overrides keyed by backend name.
    pub backends: HashMap<String, WebSearchBackendConfigRef>,
}

impl Default for WebSearchConfigRef {
    fn default() -> Self {
        Self {
            primary: "searxng".into(),
            fallbacks: vec!["brave".into(), "ddgs".into()],
            timeout_secs: 8,
            backends: HashMap::new(),
        }
    }
}

/// Resolve the Lingshu home directory.
///
/// Resolution order:
///   1. `EDGECRAB_HOME` env var
///   2. `~/.lingshu`
///
/// Duplicated from lingshu-core/config.rs to avoid a circular crate dep.
pub fn resolve_lingshu_home() -> PathBuf {
    std::env::var("EDGECRAB_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            if cfg!(test) {
                return std::env::temp_dir()
                    .join(format!("lingshu-test-home-{}", std::process::id()));
            }
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join(".lingshu")
        })
}

/// Minimal configuration view passed to tools via ToolContext.
///
/// Populated from AppConfig by the agent before tool dispatch.
/// Only includes fields that tools actually need.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(default)]
pub struct LspServerConfigRef {
    pub command: String,
    pub args: Vec<String>,
    pub file_extensions: Vec<String>,
    pub language_id: String,
    pub root_markers: Vec<String>,
    pub env: HashMap<String, String>,
    pub initialization_options: Option<serde_json::Value>,
}

#[derive(Debug, Clone)]
pub struct AppConfigRef {
    /// Whether the gateway process is running (gates send_message)
    pub gateway_running: bool,
    /// Whether Home Assistant is configured (gates ha_* tools)
    pub home_assistant_active: bool,
    /// Max file size in bytes for read operations
    pub max_file_read_bytes: usize,
    /// Max output length for terminal commands
    pub max_terminal_output: usize,
    /// Additional file roots trusted by file tools beyond the active workspace.
    pub file_allowed_roots: Vec<PathBuf>,
    /// Denied prefixes layered on top of the workspace and allow-roots policy.
    pub path_restrictions: Vec<PathBuf>,
    /// Whether LSP tools are enabled for this session.
    pub lsp_enabled: bool,
    /// Max file size eligible for LSP document sync.
    pub lsp_file_size_limit_bytes: u64,
    /// Named language-server configurations keyed by logical language/server id.
    pub lsp_servers: HashMap<String, LspServerConfigRef>,
    /// Post-write diagnostic pull budget in milliseconds (default 1500).
    pub lsp_post_write_timeout_ms: u64,
    /// Lingshu home directory (memory, skills, sessions storage root).
    ///
    /// WHY renamed from workspace_root: memory and skills tools write to
    /// `~/.lingshu/memories/` and `~/.lingshu/skills/` — NOT to the
    /// user's project CWD. Using a descriptive name prevents the same
    /// bug from recurring. The user's project CWD is `ToolContext::cwd`.
    pub lingshu_home: PathBuf,
    /// Whether subagent delegation is enabled.
    pub delegation_enabled: bool,
    /// Optional model override for delegated children.
    pub delegation_model: Option<String>,
    /// Optional provider override used when delegation_model has no prefix.
    pub delegation_provider: Option<String>,
    /// Maximum number of children allowed in a single batch delegation call.
    pub delegation_max_subagents: u32,
    /// Default max iterations per child when delegate_task omits max_iterations.
    pub delegation_max_iterations: u32,
    /// Parent active toolsets used to ensure children cannot gain capabilities.
    /// Empty means no explicit whitelist (all available toolsets).
    pub parent_active_toolsets: Vec<String>,
    /// Toolsets explicitly disabled for the current session.
    ///
    /// WHY separate from `parent_active_toolsets`: when the session uses the
    /// implicit "all toolsets" mode, the allow-list is empty but specific
    /// toolsets may still be denied via config. Dispatch-time checks need the
    /// explicit deny-list so a hallucinated tool call cannot bypass schema
    /// filtering.
    pub disabled_toolsets: Vec<String>,
    /// Tools explicitly enabled for the current session.
    ///
    /// WHY separate from toolsets: users may want one browser or MCP helper
    /// without exposing the rest of that toolset.
    pub enabled_tools: Vec<String>,
    /// Tools explicitly disabled for the current session.
    ///
    /// Disabled tools always win, even if their parent toolset is enabled.
    pub disabled_tools: Vec<String>,
    /// External skill directories to scan in addition to ~/.lingshu/skills/.
    /// Supports ~ and ${VAR} expansion (hermes-compatible paths).
    pub external_skill_dirs: Vec<String>,
    /// Skill names disabled globally or per-platform (merged).
    /// Tools (skills_list) should skip these.
    pub disabled_skills: Vec<String>,
    /// Plugin names disabled globally or per-platform (merged for the current platform).
    pub disabled_plugins: Vec<String>,
    /// Install root used for plugin discovery and runtime assets.
    pub plugin_install_dir: PathBuf,
    /// Record browser sessions as WebM video files when true.
    /// Mirrors `browser.record_sessions` in config.yaml.
    pub browser_record_sessions: bool,
    /// Browser CDP call timeout in seconds. Mirrors `browser.command_timeout`.
    pub browser_command_timeout: u64,
    /// Auto-cleanup browser recordings older than N hours.
    pub browser_recording_max_age_hours: u64,
    /// Whether automatic checkpoints are enabled.
    /// Mirrors `checkpoints.enabled` in config.yaml (default: true).
    pub checkpoints_enabled: bool,
    /// Maximum number of checkpoints to keep per working directory.
    /// Mirrors `checkpoints.max_snapshots` in config.yaml (default: 20).
    pub checkpoints_max_snapshots: u32,
    /// Total checkpoint store size cap in MB (`checkpoints.max_total_size_mb`).
    pub checkpoints_max_total_size_mb: u32,
    /// Per-file size cap when staging checkpoints (`checkpoints.max_file_size_mb`).
    pub checkpoints_max_file_size_mb: u32,
    /// Whether computer_use desktop control is enabled (`computer_use.enabled`).
    pub computer_use_enabled: bool,
    /// Screenshot history cap for compression (`computer_use.keep_last_n_screenshots`).
    pub computer_use_keep_last_n_screenshots: u32,
    /// Require approval for destructive computer_use actions.
    pub computer_use_confirm_destructive: bool,
    /// cua-driver command (`computer_use.cua_driver_cmd`).
    pub computer_use_cua_cmd: String,
    /// Active session model in `provider/model` form.
    pub active_model: String,
    /// Skills to preload into the system prompt (from -s/--skill flags).
    pub preloaded_skills: Vec<String>,
    /// Stage skill_manage writes for `/skills approve` when true.
    pub skills_write_approval: bool,
    /// Stage memory_write for `/memory approve` when true.
    pub memory_write_approval: bool,
    /// Dangerous-command approval mode (`approvals.mode` in config.yaml).
    pub approval_mode: lingshu_security::approval::ApprovalMode,
    /// Optional model for smart approval (`approvals.smart_model`).
    pub approvals_smart_model: Option<String>,
    /// Enable kanban board tools (`kanban.enabled`).
    pub kanban_enabled: bool,
    /// Kanban claim TTL seconds (`kanban.claim_ttl_secs`).
    pub kanban_claim_ttl_secs: u32,
    /// Default per-task max runtime seconds (`kanban.default_max_runtime_secs`; 0 = none).
    pub kanban_default_max_runtime_secs: u32,
    /// Optional custom skills hub base URL (`skills.hub_url`) for well-known discovery.
    pub skills_hub_url: Option<String>,

    /// Env-var names allowed to bypass the subprocess security blocklist.
    ///
    /// Populated from `terminal.env_passthrough` in config.yaml and
    /// injected into the local env-passthrough registry on agent startup.
    /// Skills that declare `required_environment_variables` also feed into
    /// this registry at load time via `register_env_passthrough()`.
    pub terminal_env_passthrough: Vec<String>,

    // ── Terminal backend configuration (gap/backend B-01a/B-01b/B-02/B-03/B-04) ──
    /// Which execution backend the terminal tool should use.
    /// Defaults to `BackendKind::Local` (direct host execution).
    /// Override via `EDGECRAB_TERMINAL_BACKEND=docker|ssh|modal` or config.yaml.
    pub terminal_backend: BackendKind,

    /// Docker-specific terminal backend configuration.
    pub terminal_docker: DockerBackendConfig,

    /// SSH-specific terminal backend configuration.
    pub terminal_ssh: SshBackendConfig,

    /// Modal-specific terminal backend configuration.
    pub terminal_modal: ModalBackendConfig,

    /// Daytona-specific terminal backend configuration.
    pub terminal_daytona: DaytonaBackendConfig,

    /// Singularity-specific terminal backend configuration.
    pub terminal_singularity: SingularityBackendConfig,
    /// Optional dedicated provider for auxiliary side tasks such as vision.
    pub auxiliary_provider: Option<String>,
    /// Optional dedicated model for auxiliary side tasks such as vision.
    pub auxiliary_model: Option<String>,
    /// Optional base URL override for the auxiliary provider.
    pub auxiliary_base_url: Option<String>,
    /// Optional API-key environment variable for the auxiliary provider.
    pub auxiliary_api_key_env: Option<String>,
    /// Preferred text-to-speech provider from config (`tts.provider`).
    pub tts_provider: Option<String>,
    /// Preferred text-to-speech voice from config (`tts.voice`).
    pub tts_voice: Option<String>,
    /// Optional Edge TTS rate override from config (`tts.rate`).
    pub tts_rate: Option<String>,
    /// Optional provider-specific TTS model (`tts.model`).
    pub tts_model: Option<String>,
    /// Optional ElevenLabs voice id from config (`tts.elevenlabs_voice_id`).
    pub tts_elevenlabs_voice_id: Option<String>,
    /// Optional ElevenLabs model id from config (`tts.elevenlabs_model_id`).
    pub tts_elevenlabs_model_id: Option<String>,
    /// Environment variable name for ElevenLabs credentials.
    pub tts_elevenlabs_api_key_env: Option<String>,
    /// Preferred speech-to-text provider from config (`stt.provider`).
    pub stt_provider: Option<String>,
    /// Preferred local Whisper model from config (`stt.whisper_model`).
    pub stt_whisper_model: Option<String>,
    /// Preferred image-generation provider from config (`image_generation.provider`).
    pub image_provider: Option<String>,
    /// Preferred image-generation model from config (`image_generation.model`).
    pub image_model: Option<String>,
    /// Whether the `moa` tool is enabled for this session.
    pub moa_enabled: bool,
    /// Default reference models for the `moa` tool.
    pub moa_reference_models: Vec<String>,
    /// Default aggregator model for the `moa` tool.
    pub moa_aggregator_model: Option<String>,
    /// Whether tool-result spill-to-artifact is enabled (default: true).
    pub result_spill: bool,
    /// Byte threshold above which tool results are spilled to artifact files.
    pub result_spill_threshold: usize,
    /// Number of preview lines kept in the spill stub.
    pub result_spill_preview_lines: usize,
    /// Aggregate tool-result char budget per assistant turn (`0` = off).
    pub result_turn_budget_chars: usize,
    /// Maximum write payload size in KiB for file mutation tools.
    ///
    /// WHY FP16 "Defaults protect, overrides empower": the default (32 KiB)
    /// is safe for most LLM providers. Power users with models that handle
    /// larger JSON tool arguments can increase this. The value is clamped to
    /// [8, 256] KiB to prevent mis-configuration.
    pub max_write_payload_kib: usize,
    /// Default `write_file` create_dirs when the model omits the flag.
    pub local_write_create_dirs: bool,
    /// Absolute completion cap for local tool turns (yaml default; env overrides).
    pub local_max_tool_turn_tokens: usize,
    /// Pluggable web search backend chain configuration.
    pub web_search: WebSearchConfigRef,
    /// Hermes-aligned web tool backend overrides (`web:` in config.yaml).
    pub web: WebToolsConfigRef,
}

impl Default for AppConfigRef {
    fn default() -> Self {
        Self {
            gateway_running: false,
            home_assistant_active: false,
            max_file_read_bytes: 2 * 1024 * 1024, // 2 MB
            max_terminal_output: 100_000,         // 100K chars
            file_allowed_roots: Vec::new(),
            path_restrictions: Vec::new(),
            lsp_enabled: true,
            lsp_file_size_limit_bytes: 10_000_000,
            lsp_servers: HashMap::new(),
            lsp_post_write_timeout_ms: 1_500,
            lingshu_home: resolve_lingshu_home(),
            delegation_enabled: true,
            delegation_model: None,
            delegation_provider: None,
            delegation_max_subagents: 3,
            delegation_max_iterations: 50,
            parent_active_toolsets: Vec::new(),
            disabled_toolsets: Vec::new(),
            enabled_tools: Vec::new(),
            disabled_tools: Vec::new(),
            external_skill_dirs: Vec::new(),
            disabled_skills: Vec::new(),
            disabled_plugins: Vec::new(),
            plugin_install_dir: resolve_lingshu_home().join("plugins"),
            browser_record_sessions: false,
            browser_command_timeout: 30,
            browser_recording_max_age_hours: 72,
            checkpoints_enabled: true,
            checkpoints_max_snapshots: 20,
            checkpoints_max_total_size_mb: 200,
            checkpoints_max_file_size_mb: 10,
            computer_use_enabled: false,
            computer_use_keep_last_n_screenshots: 1,
            computer_use_confirm_destructive: true,
            computer_use_cua_cmd: "cua-driver".into(),
            active_model: String::new(),
            preloaded_skills: Vec::new(),
            skills_write_approval: false,
            memory_write_approval: false,
            approval_mode: lingshu_security::approval::ApprovalMode::Manual,
            approvals_smart_model: None,
            kanban_enabled: false,
            kanban_claim_ttl_secs: 900,
            kanban_default_max_runtime_secs: 0,
            skills_hub_url: None,
            terminal_env_passthrough: Vec::new(),
            terminal_backend: BackendKind::Local,
            terminal_docker: DockerBackendConfig::default(),
            terminal_ssh: SshBackendConfig::default(),
            terminal_modal: ModalBackendConfig::default(),
            terminal_daytona: DaytonaBackendConfig::default(),
            terminal_singularity: SingularityBackendConfig::default(),
            auxiliary_provider: None,
            auxiliary_model: None,
            auxiliary_base_url: None,
            auxiliary_api_key_env: None,
            tts_provider: None,
            tts_voice: None,
            tts_rate: None,
            tts_model: None,
            tts_elevenlabs_voice_id: None,
            tts_elevenlabs_model_id: None,
            tts_elevenlabs_api_key_env: None,
            stt_provider: None,
            stt_whisper_model: None,
            image_provider: None,
            image_model: None,
            moa_enabled: true,
            moa_reference_models: Vec::new(),
            moa_aggregator_model: None,
            result_spill: true,
            result_spill_threshold: 16_384,
            result_spill_preview_lines: 80,
            result_turn_budget_chars: 200_000,
            max_write_payload_kib: crate::edit_contract::DEFAULT_MAX_MUTATION_PAYLOAD_KIB,
            local_write_create_dirs: true,
            local_max_tool_turn_tokens:
                crate::mutation_turn_policy::LOCAL_TOOL_TURN_ABS_MAX_TOKENS,
            web_search: WebSearchConfigRef::default(),
            web: WebToolsConfigRef::default(),
        }
    }
}

impl AppConfigRef {
    /// Effective maximum write payload in bytes, clamped to [8 KiB, 256 KiB].
    pub fn max_write_payload_bytes(&self) -> usize {
        crate::edit_contract::clamp_write_limit_bytes(self.max_write_payload_kib)
    }

    /// Whether a toolset is allowed in the current session.
    ///
    /// Empty `parent_active_toolsets` means "no explicit whitelist" rather than
    /// "nothing is allowed". The disabled list always wins.
    pub fn is_toolset_enabled(&self, toolset: &str) -> bool {
        (self.parent_active_toolsets.is_empty()
            || self.parent_active_toolsets.iter().any(|t| t == toolset))
            && !self.disabled_toolsets.iter().any(|t| t == toolset)
    }

    /// Whether a specific tool is allowed in the current session.
    pub fn is_tool_enabled(&self, tool_name: &str, toolset: &str) -> bool {
        crate::toolsets::tool_enabled(
            Some(&self.parent_active_toolsets),
            Some(&self.disabled_toolsets),
            Some(&self.enabled_tools),
            Some(&self.disabled_tools),
            tool_name,
            toolset,
        )
    }

    pub fn is_plugin_enabled(&self, plugin_name: &str) -> bool {
        !self
            .disabled_plugins
            .iter()
            .any(|candidate| candidate == plugin_name)
    }

    /// Build the effective file path policy for a session workspace.
    pub fn file_path_policy(&self, cwd: &std::path::Path) -> PathPolicy {
        let file_tools_tmp_dir = self.file_tools_tmp_dir();
        let _ = std::fs::create_dir_all(&file_tools_tmp_dir);

        let mut allowed = self.file_allowed_roots.clone();
        // On Termux, add the Termux data directory so file tools can access
        // Termux-installed packages, shared storage, and user scripts.
        if *lingshu_types::IS_TERMUX {
            if let Ok(prefix) = std::env::var("PREFIX") {
                allowed.push(std::path::PathBuf::from(prefix));
            } else {
                allowed.push(std::path::PathBuf::from("/data/data/com.termux/files"));
            }
        }

        PathPolicy::new(cwd.to_path_buf())
            .with_virtual_tmp_root(file_tools_tmp_dir)
            .with_allowed_roots(allowed)
            .with_denied_roots(self.path_restrictions.clone())
    }

    pub fn lsp_server_for_extension(&self, ext: &str) -> Option<(&str, &LspServerConfigRef)> {
        let ext = ext.trim_start_matches('.').to_ascii_lowercase();
        self.lsp_servers.iter().find_map(|(name, cfg)| {
            cfg.file_extensions
                .iter()
                .any(|candidate| candidate.eq_ignore_ascii_case(&ext))
                .then_some((name.as_str(), cfg))
        })
    }

    // ── Well-known directory helpers ──────────────────────────────────────
    //
    // WHY methods instead of ad-hoc `.join("image_cache")` calls:
    // Each directory name appears in multiple places (gateway adapters, vision
    // tool, tests). A single method per directory is the single source of truth;
    // rename one string and every caller updates automatically.

    /// Where the TUI saves clipboard-pasted images before sending to vision.
    pub fn tui_images_dir(&self) -> std::path::PathBuf {
        self.lingshu_home.join("images")
    }

    /// Where the WhatsApp Baileys bridge caches inbound images.
    ///
    /// The Baileys Node bridge writes `img_<hex>.{jpg,png,…}` here when a
    /// WhatsApp message with a photo arrives.  The Rust gateway reads the
    /// `mediaUrls` list from the bridge and forwards the path to the agent.
    /// vision_analyze must trust this directory so the path-jail check passes.
    pub fn gateway_image_cache_dir(&self) -> std::path::PathBuf {
        self.lingshu_home.join("image_cache")
    }

    /// Root directory for Rust-native gateway adapter media downloads.
    ///
    /// Each adapter nests its files in a platform-named sub-directory, e.g.:
    ///   `gateway_media/telegram/`
    ///   `gateway_media/discord/`
    ///
    /// vision_analyze trusts the root so all current and future platform
    /// sub-directories are covered without per-platform changes.
    pub fn gateway_media_dir(&self) -> std::path::PathBuf {
        self.lingshu_home.join("gateway_media")
    }

    /// Where gateway platform adapters cache inbound document attachments.
    ///
    /// The WhatsApp Baileys bridge writes PDF and document files here
    /// (e.g. `doc_<hex>_filename.pdf`) when a document message arrives.
    /// `pdf_to_markdown` and other file tools must trust this directory so the
    /// path-jail check passes when the agent processes a gateway-received document.
    pub fn document_cache_dir(&self) -> std::path::PathBuf {
        self.lingshu_home.join("document_cache")
    }

    /// Dedicated temp root for file tools.
    ///
    /// WHY not host `/tmp`: global temp directories are shared, nondeterministic,
    /// and can expose unrelated process files. File tools get an Lingshu-owned
    /// temp tree with stable semantics instead.
    pub fn file_tools_tmp_dir(&self) -> std::path::PathBuf {
        shared_tmp_dir(&self.lingshu_home)
    }
}