Skip to main content

kcode_kennedy_session_services/
lib.rs

1//! Typed access to Kennedy's in-process session capabilities.
2
3#![forbid(unsafe_code)]
4
5use std::sync::Arc;
6
7use kcode_kweb_db::{Node, NodeId, ObjectId, Provenance};
8use kcode_kweb_manager::KwebManager;
9use kcode_server_object_envelopes::{StoredFile, decode_file, encode_file};
10use serde_json::Value;
11use uuid::Uuid;
12
13#[derive(Clone)]
14pub struct LocalServices {
15    pub kmap: KwebManager,
16    pub intelligence: kcode_intelligence_router::Intelligence,
17    pub history: kcode_session_history::SessionHistory,
18    pub speech_classifier: Arc<kcode_speaker_system::SpeechClassifier>,
19    pub dev_tools: kcode_dev_tools::Service,
20    pub agents: kcode_agent_runtime::AgentRuntime,
21    pub telegram: kcode_telegram_session_coordinator::Service,
22}
23#[derive(Debug, Clone)]
24pub struct ApiError {
25    message: String,
26    pub receipt: Option<Box<kcode_intelligence_router::UsageReceipt>>,
27}
28impl std::fmt::Display for ApiError {
29    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        formatter.write_str(&self.message)
31    }
32}
33
34impl std::error::Error for ApiError {}
35#[derive(Clone)]
36pub struct Api {
37    services: Arc<LocalServices>,
38    task_board: Option<kcode_task_board::TaskBoard>,
39}
40impl Api {
41    pub fn new(services: LocalServices) -> Self {
42        Self {
43            services: Arc::new(services),
44            task_board: None,
45        }
46    }
47
48    pub fn with_task_board(mut self, task_board: kcode_task_board::TaskBoard) -> Self {
49        self.task_board = Some(task_board);
50        self
51    }
52
53    pub fn kmap(&self) -> &KwebManager {
54        &self.services.kmap
55    }
56
57    pub fn telegram(&self) -> &kcode_telegram_session_coordinator::Service {
58        &self.services.telegram
59    }
60
61    pub fn task_board(&self) -> Option<&kcode_task_board::TaskBoard> {
62        self.task_board.as_ref()
63    }
64
65    pub fn create_history_session(
66        &self,
67        input: kcode_session_history::NewSession,
68    ) -> anyhow::Result<kcode_session_history::Session> {
69        self.services.history.create_session(input)
70    }
71
72    pub fn history_session(
73        &self,
74        metadata: kcode_session_history::chatend::SessionMetadata,
75        provider_model: &str,
76    ) -> anyhow::Result<kcode_session_history::Session> {
77        self.services
78            .history
79            .open_session_with_provider_model(metadata, Some(provider_model))
80    }
81
82    pub fn kmap_node(&self, node_id: &str) -> Result<Node, ApiError> {
83        let node_id = node_id.parse::<NodeId>().map_err(local_api_error)?;
84        self.services.kmap.get_node(node_id).map_err(kmap_error)
85    }
86
87    pub fn commit_kweb_session(
88        &self,
89        input: kcode_commit_session::CommitRequest,
90    ) -> Result<kcode_commit_session::CommitReceipt, ApiError> {
91        self.services.kmap.commit_session(input).map_err(kmap_error)
92    }
93
94    pub fn kmap_file(&self, object_id: &str) -> Result<StoredFile, ApiError> {
95        let object_id = object_id.parse::<ObjectId>().map_err(local_api_error)?;
96        let bytes = self
97            .services
98            .kmap
99            .get_object(object_id)
100            .map_err(kmap_error)?;
101        decode_file(object_id, bytes).map_err(local_api_error)
102    }
103
104    pub fn save_generated_image(
105        &self,
106        bytes: Vec<u8>,
107        file_name: &str,
108        media_type: &str,
109        model: &str,
110    ) -> Result<String, ApiError> {
111        let bytes = encode_file(
112            "generated-image",
113            Some(file_name),
114            media_type,
115            Some("image"),
116            bytes,
117        )
118        .map_err(local_api_error)?;
119        self.services
120            .kmap
121            .store_object(
122                Provenance {
123                    author: model.into(),
124                    source: "kennedy-generated-image".into(),
125                    source_created_at: chrono::Utc::now(),
126                    data: "Image generated or modified through Kennedy intelligence.".into(),
127                },
128                bytes,
129            )
130            .map(|id| id.to_string())
131            .map_err(kmap_error)
132    }
133
134    pub fn agent_runtime(&self) -> kcode_agent_runtime::AgentRuntime {
135        self.services.agents.clone()
136    }
137
138    pub async fn search(
139        &self,
140        user_id: &str,
141        request: kcode_intelligence_router::SearchRequest,
142    ) -> Result<
143        kcode_intelligence_router::Accounted<kcode_intelligence_router::SearchResponse>,
144        ApiError,
145    > {
146        self.services
147            .intelligence
148            .for_user(user_id)
149            .map_err(intelligence_error)?
150            .search(request)
151            .await
152            .map_err(intelligence_error)
153    }
154
155    pub async fn fetch(
156        &self,
157        user_id: &str,
158        request: kcode_intelligence_router::FetchRequest,
159    ) -> Result<kcode_intelligence_router::FetchResponse, ApiError> {
160        self.services
161            .intelligence
162            .for_user(user_id)
163            .map_err(intelligence_error)?
164            .fetch(request)
165            .await
166            .map_err(intelligence_error)
167    }
168
169    pub async fn managed_source_execute(
170        &self,
171        session_id: &str,
172        name: &str,
173        arguments: Value,
174        objects: Vec<Vec<u8>>,
175    ) -> Result<kcode_dev_tools::ToolExecution, ApiError> {
176        let mut execution = self
177            .services
178            .dev_tools
179            .execute(session_id.to_owned(), name.to_owned(), arguments, objects)
180            .await
181            .map_err(dev_tools_error)?;
182        let mut object_ids = Vec::with_capacity(execution.objects.len());
183        for bytes in std::mem::take(&mut execution.objects) {
184            object_ids.push(
185                self.services
186                    .kmap
187                    .store_object(
188                        Provenance {
189                            author: "Kennedy".into(),
190                            source: "kennedy-rust-binary".into(),
191                            source_created_at: chrono::Utc::now(),
192                            data: "Output payload from a managed Rust-binary call.".into(),
193                        },
194                        bytes,
195                    )
196                    .map_err(kmap_error)?
197                    .to_string(),
198            );
199        }
200        append_object_ids(&mut execution.text, &object_ids);
201        Ok(execution)
202    }
203
204    pub async fn execute_speech_classification_tool(
205        &self,
206        name: &str,
207        arguments: Value,
208    ) -> Result<String, ApiError> {
209        let call =
210            kcode_speaker_system::decode_ktool(name, &arguments).map_err(speech_ktool_error)?;
211        let classifier = Arc::clone(&self.services.speech_classifier);
212        tokio::task::spawn_blocking(move || classifier.execute_ktool(call))
213            .await
214            .map_err(speech_task_error)?
215            .map_err(speech_ktool_error)
216    }
217
218    pub async fn release_managed_sources(&self, session_id: &str) {
219        if let Err(error) = self.services.dev_tools.release(session_id.to_owned()).await {
220            tracing::warn!(error=%error.message, "Managed-source session release failed");
221        }
222    }
223
224    #[allow(clippy::too_many_arguments)]
225    pub async fn transcribe_audio(
226        &self,
227        user_id: &str,
228        model: &str,
229        prompt: &str,
230        bytes: Vec<u8>,
231        filename: String,
232        mime: &str,
233        temperature: Option<f32>,
234        parent_operation_id: Uuid,
235    ) -> Result<
236        kcode_intelligence_router::Accounted<kcode_intelligence_router::TranscriptionResponse>,
237        ApiError,
238    > {
239        self.services
240            .intelligence
241            .for_user(user_id)
242            .map_err(intelligence_error)?
243            .transcribe(kcode_intelligence_router::TranscriptionRequest {
244                prompt: prompt.to_owned(),
245                model: model.to_owned(),
246                media: kcode_intelligence_router::Media::audio(bytes, filename, mime)
247                    .map_err(intelligence_error)?,
248                temperature,
249                operation_id: Uuid::new_v4(),
250                parent_operation_id: Some(parent_operation_id),
251            })
252            .await
253            .map_err(intelligence_error)
254    }
255
256    pub async fn extract_document(
257        &self,
258        bytes: Vec<u8>,
259        filename: String,
260        mime: &str,
261    ) -> Result<kcode_intelligence_router::DocumentExtraction, ApiError> {
262        self.services
263            .intelligence
264            .extract_document(kcode_intelligence_router::Document {
265                bytes,
266                file_name: filename,
267                content_type: mime.to_owned(),
268            })
269            .await
270            .map_err(intelligence_error)
271    }
272
273    #[allow(clippy::too_many_arguments)]
274    pub async fn annotate_media(
275        &self,
276        user_id: &str,
277        model: &str,
278        prompt: &str,
279        bytes: Vec<u8>,
280        filename: String,
281        mime: &str,
282        parent_operation_id: Uuid,
283    ) -> Result<
284        kcode_intelligence_router::Accounted<kcode_intelligence_router::AnnotationResponse>,
285        ApiError,
286    > {
287        self.services
288            .intelligence
289            .for_user(user_id)
290            .map_err(intelligence_error)?
291            .annotate(kcode_intelligence_router::AnnotationRequest {
292                prompt: prompt.to_owned(),
293                model: model.to_owned(),
294                media: media_for_annotation(bytes, filename, mime).map_err(intelligence_error)?,
295                operation_id: Uuid::new_v4(),
296                parent_operation_id: Some(parent_operation_id),
297            })
298            .await
299            .map_err(intelligence_error)
300    }
301
302    pub async fn generate_image(
303        &self,
304        user_id: &str,
305        model: &str,
306        prompt: &str,
307        references: Vec<(Vec<u8>, String, String)>,
308        parent_operation_id: Uuid,
309    ) -> Result<
310        kcode_intelligence_router::Accounted<kcode_intelligence_router::ImageResponse>,
311        ApiError,
312    > {
313        let references = references
314            .into_iter()
315            .map(|(bytes, filename, mime)| {
316                media_for_image(bytes, filename, &mime).map_err(intelligence_error)
317            })
318            .collect::<Result<Vec<_>, _>>()?;
319        self.services
320            .intelligence
321            .for_user(user_id)
322            .map_err(intelligence_error)?
323            .generate_image(kcode_intelligence_router::ImageRequest {
324                model: model.to_owned(),
325                prompt: prompt.to_owned(),
326                references,
327                operation_id: Uuid::new_v4(),
328                parent_operation_id: Some(parent_operation_id),
329            })
330            .await
331            .map_err(intelligence_error)
332    }
333}
334
335fn kmap_error(error: kcode_kweb_manager::Error) -> ApiError {
336    let message = match error.kind() {
337        kcode_kweb_manager::ErrorKind::InvalidInput
338        | kcode_kweb_manager::ErrorKind::NotFound
339        | kcode_kweb_manager::ErrorKind::Conflict => error.to_string(),
340        _ => "An unexpected Kmap database error occurred.".into(),
341    };
342    ApiError {
343        message,
344        receipt: None,
345    }
346}
347
348fn intelligence_error(error: kcode_intelligence_router::Error) -> ApiError {
349    ApiError {
350        message: error.message().into(),
351        receipt: error.receipt().cloned().map(Box::new),
352    }
353}
354
355fn append_object_ids(text: &mut String, object_ids: &[String]) {
356    if object_ids.is_empty() {
357        return;
358    }
359    if !text.is_empty() && !text.ends_with('\n') {
360        text.push('\n');
361    }
362    text.push_str(&object_ids.join("\n"));
363}
364
365fn dev_tools_error(error: kcode_dev_tools::ToolError) -> ApiError {
366    ApiError {
367        message: error.message,
368        receipt: None,
369    }
370}
371
372fn speech_task_error(error: tokio::task::JoinError) -> ApiError {
373    tracing::error!(%error, "In-process speaker-classification task stopped unexpectedly");
374    ApiError {
375        message: "An unexpected Kennedy speaker-classification error occurred.".into(),
376        receipt: None,
377    }
378}
379
380fn speech_ktool_error(error: kcode_speaker_system::KtoolError) -> ApiError {
381    let kcode_speaker_system::KtoolError::Classifier(error) = error else {
382        return ApiError {
383            message: error.to_string(),
384            receipt: None,
385        };
386    };
387    let internal = matches!(
388        error,
389        kcode_speaker_system::Error::Storage(_)
390            | kcode_speaker_system::Error::CorruptStorage(_)
391            | kcode_speaker_system::Error::Model(_)
392    );
393    ApiError {
394        message: if internal {
395            tracing::error!(%error, "Speaker-classification storage failed");
396            "An unexpected Kennedy speaker-classification error occurred.".into()
397        } else {
398            error.to_string()
399        },
400        receipt: None,
401    }
402}
403
404fn local_api_error(error: impl std::fmt::Display) -> ApiError {
405    ApiError {
406        message: error.to_string(),
407        receipt: None,
408    }
409}
410
411fn media_for_annotation(
412    bytes: Vec<u8>,
413    filename: String,
414    mime: &str,
415) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
416    let normalized = mime
417        .split(';')
418        .next()
419        .unwrap_or("application/octet-stream")
420        .trim()
421        .to_ascii_lowercase();
422    let kind = if normalized.starts_with("image/") {
423        kcode_intelligence_router::MediaKind::Image
424    } else if normalized.starts_with("audio/")
425        || matches!(normalized.as_str(), "application/ogg" | "video/ogg")
426        || filename.rsplit_once('.').is_some_and(|(_, extension)| {
427            matches!(
428                extension.to_ascii_lowercase().as_str(),
429                "ogg" | "oga" | "opus"
430            )
431        })
432    {
433        kcode_intelligence_router::MediaKind::Audio
434    } else if normalized.starts_with("video/") {
435        kcode_intelligence_router::MediaKind::Video
436    } else {
437        return Err(kcode_intelligence_router::Error::invalid(
438            "annotation requires image, audio, or video media",
439        ));
440    };
441    kcode_intelligence_router::Media::new(kind, bytes, filename, normalized)
442}
443
444fn media_for_image(
445    bytes: Vec<u8>,
446    filename: String,
447    mime: &str,
448) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
449    let normalized = mime
450        .split(';')
451        .next()
452        .unwrap_or("application/octet-stream")
453        .trim()
454        .to_ascii_lowercase();
455    if !normalized.starts_with("image/") {
456        return Err(kcode_intelligence_router::Error::invalid(
457            "image references must use an image content type",
458        ));
459    }
460    kcode_intelligence_router::Media::new(
461        kcode_intelligence_router::MediaKind::Image,
462        bytes,
463        filename,
464        normalized,
465    )
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn speech_ktool_errors_keep_the_existing_public_failure_boundary() {
474        let malformed = speech_ktool_error(kcode_speaker_system::KtoolError::InvalidArguments {
475            tool: kcode_speaker_system::IDENTIFY_TOOL,
476            source: serde_json::from_str::<Value>("{").unwrap_err(),
477        });
478        assert_eq!(
479            malformed.message,
480            "decoding kcode-speaker-system/identify arguments"
481        );
482
483        let validation = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
484            kcode_speaker_system::Error::Validation {
485                field: "speaker_id".into(),
486                message: "must not be empty".into(),
487            },
488        ));
489        assert_eq!(validation.message, "speaker_id: must not be empty");
490
491        let storage = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
492            kcode_speaker_system::Error::Storage("private detail".into()),
493        ));
494        assert_eq!(
495            storage.message,
496            "An unexpected Kennedy speaker-classification error occurred."
497        );
498    }
499}