use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::filter::mask_secrets;
use kindling_provider::{retrieve_at, LocalFtsProvider};
use kindling_store::{SqliteKindlingStore, StoreError, StoreOptions};
use kindling_types::{
Capsule, CapsuleInput, CapsuleStatus, CapsuleType, Id, Observation, ObservationInput, Pin,
PinInput, PinTargetType, RetrieveOptions, RetrieveResult, ScopeIds, Summary, SummaryInput,
Timestamp,
};
use crate::context::{PreCompactContext, ResolvedPin, SessionStartContext};
use crate::error::{ServiceError, ServiceResult};
use crate::validation;
#[derive(Debug, Clone)]
pub struct OpenCapsuleOptions {
pub kind: CapsuleType,
pub intent: String,
pub scope_ids: ScopeIds,
pub id: Option<Id>,
}
#[derive(Debug, Clone, Default)]
pub struct CloseCapsuleOptions {
pub generate_summary: bool,
pub summary_content: Option<String>,
pub confidence: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct AppendObservationOptions {
pub capsule_id: Option<Id>,
pub validate: bool,
}
impl Default for AppendObservationOptions {
fn default() -> Self {
Self {
capsule_id: None,
validate: true,
}
}
}
#[derive(Debug, Clone)]
pub struct CreatePinOptions {
pub target_type: PinTargetType,
pub target_id: Id,
pub note: Option<String>,
pub ttl_ms: Option<i64>,
pub scope_ids: Option<ScopeIds>,
}
pub struct KindlingService {
store: SqliteKindlingStore,
}
impl KindlingService {
pub fn new(store: SqliteKindlingStore) -> Self {
Self { store }
}
pub fn open(path: &Path) -> ServiceResult<Self> {
Ok(Self::new(SqliteKindlingStore::open(path)?))
}
pub fn open_with_options(path: &Path, options: &StoreOptions) -> ServiceResult<Self> {
Ok(Self::new(SqliteKindlingStore::open_with_options(
path, options,
)?))
}
pub fn open_in_memory() -> ServiceResult<Self> {
Ok(Self::new(SqliteKindlingStore::open_in_memory()?))
}
pub fn store(&self) -> &SqliteKindlingStore {
&self.store
}
pub fn open_capsule(&self, options: OpenCapsuleOptions) -> ServiceResult<Capsule> {
self.open_capsule_at(options, now_ms())
}
pub fn open_capsule_at(
&self,
options: OpenCapsuleOptions,
now: Timestamp,
) -> ServiceResult<Capsule> {
if options.kind == CapsuleType::Session {
if let Some(session_id) = options.scope_ids.session_id.as_deref() {
if let Some(existing) = self.store.get_open_capsule_for_session(session_id)? {
return Err(ServiceError::Conflict(format!(
"session {session_id} already has an open capsule ({})",
existing.id
)));
}
}
}
let capsule = validation::validate_capsule(
CapsuleInput {
id: options.id,
kind: options.kind,
intent: options.intent,
status: Some(CapsuleStatus::Open),
opened_at: None,
closed_at: None,
scope_ids: options.scope_ids,
observation_ids: None,
summary_id: None,
},
now,
)
.map_err(ServiceError::Validation)?;
self.store.create_capsule(&capsule)?;
Ok(capsule)
}
pub fn close_capsule(
&self,
capsule_id: &str,
options: CloseCapsuleOptions,
) -> ServiceResult<Capsule> {
self.close_capsule_at(capsule_id, options, now_ms())
}
pub fn close_capsule_at(
&self,
capsule_id: &str,
options: CloseCapsuleOptions,
now: Timestamp,
) -> ServiceResult<Capsule> {
let mut capsule = match self.store.get_capsule(capsule_id)? {
None => return Err(ServiceError::NotFound(capsule_id.to_string())),
Some(capsule) => capsule,
};
if capsule.status == CapsuleStatus::Closed {
return Err(ServiceError::AlreadyClosed(capsule_id.to_string()));
}
if options.generate_summary {
if let Some(content) = options.summary_content {
let summary = validation::validate_summary(
SummaryInput {
id: None,
capsule_id: capsule_id.to_string(),
content,
confidence: options.confidence.unwrap_or(1.0),
created_at: Some(now),
evidence_refs: Vec::new(),
},
now,
)
.map_err(ServiceError::Validation)?;
self.store.insert_summary(&summary)?;
}
}
match self.store.close_capsule(capsule_id, Some(now), None) {
Ok(()) => {}
Err(StoreError::CapsuleNotOpen(_)) => {
return Err(ServiceError::AlreadyClosed(capsule_id.to_string()))
}
Err(err) => return Err(err.into()),
}
capsule.status = CapsuleStatus::Closed;
capsule.closed_at = Some(now);
Ok(capsule)
}
pub fn append_observation(
&self,
input: ObservationInput,
options: AppendObservationOptions,
) -> ServiceResult<Observation> {
self.append_observation_at(input, options, now_ms())
}
pub fn append_observation_at(
&self,
input: ObservationInput,
options: AppendObservationOptions,
now: Timestamp,
) -> ServiceResult<Observation> {
let mut observation = if options.validate {
validation::validate_observation(input, now).map_err(ServiceError::Validation)?
} else {
validation::normalize_observation(input, now)
};
observation.content = mask_secrets(&observation.content);
self.store.insert_observation(&observation)?;
if let Some(capsule_id) = options.capsule_id.as_deref() {
self.store
.attach_observation_to_capsule(capsule_id, &observation.id)?;
}
Ok(observation)
}
pub fn retrieve(&self, options: RetrieveOptions) -> ServiceResult<RetrieveResult> {
self.retrieve_at(options, now_ms())
}
pub fn retrieve_at(
&self,
options: RetrieveOptions,
now: Timestamp,
) -> ServiceResult<RetrieveResult> {
let provider = LocalFtsProvider::from_store(&self.store);
Ok(retrieve_at(&self.store, &provider, &options, now)?)
}
pub fn pin(&self, options: CreatePinOptions) -> ServiceResult<Pin> {
self.pin_at(options, now_ms())
}
pub fn pin_at(&self, options: CreatePinOptions, now: Timestamp) -> ServiceResult<Pin> {
let expires_at = options.ttl_ms.map(|ttl| now + ttl);
let pin = validation::validate_pin(
PinInput {
id: None,
target_type: options.target_type,
target_id: options.target_id,
reason: options.note,
created_at: Some(now),
expires_at,
scope_ids: options.scope_ids.unwrap_or_default(),
},
now,
)
.map_err(ServiceError::Validation)?;
self.store.insert_pin(&pin)?;
Ok(pin)
}
pub fn unpin(&self, pin_id: &str) -> ServiceResult<()> {
self.store.delete_pin(pin_id)?;
Ok(())
}
pub fn forget(&self, observation_id: &str) -> ServiceResult<()> {
self.store.redact_observation(observation_id)?;
Ok(())
}
pub fn get_capsule(&self, capsule_id: &str) -> ServiceResult<Option<Capsule>> {
Ok(self.store.get_capsule(capsule_id)?)
}
pub fn get_open_capsule(&self, session_id: &str) -> ServiceResult<Option<Capsule>> {
Ok(self.store.get_open_capsule_for_session(session_id)?)
}
pub fn get_observation(&self, observation_id: &str) -> ServiceResult<Option<Observation>> {
Ok(self.store.get_observation_by_id(observation_id)?)
}
pub fn get_summary(&self, summary_id: &str) -> ServiceResult<Option<Summary>> {
Ok(self.store.get_summary_by_id(summary_id)?)
}
pub fn get_latest_summary(&self, capsule_id: &str) -> ServiceResult<Option<Summary>> {
Ok(self.store.get_latest_summary_for_capsule(capsule_id)?)
}
pub fn list_pins(&self, scope: Option<&ScopeIds>) -> ServiceResult<Vec<Pin>> {
self.list_pins_at(scope, now_ms())
}
pub fn list_pins_at(
&self,
scope: Option<&ScopeIds>,
now: Timestamp,
) -> ServiceResult<Vec<Pin>> {
Ok(self.store.list_active_pins(scope, Some(now))?)
}
pub fn session_start_context(
&self,
scope: &ScopeIds,
max_results: u32,
) -> ServiceResult<SessionStartContext> {
self.session_start_context_at(scope, max_results, now_ms())
}
pub fn session_start_context_at(
&self,
scope: &ScopeIds,
max_results: u32,
now: Timestamp,
) -> ServiceResult<SessionStartContext> {
let pins = self.resolved_active_pins(scope, now)?;
let recent = self
.store
.query_observations(Some(scope), None, None, max_results)?;
Ok(SessionStartContext { pins, recent })
}
pub fn pre_compact_context(&self, scope: &ScopeIds) -> ServiceResult<PreCompactContext> {
self.pre_compact_context_at(scope, now_ms())
}
pub fn pre_compact_context_at(
&self,
scope: &ScopeIds,
now: Timestamp,
) -> ServiceResult<PreCompactContext> {
let pins = self.resolved_active_pins(scope, now)?;
let latest_summary = self
.store
.latest_summary_for_scope(Some(scope))?
.filter(|s| !s.content.is_empty());
Ok(PreCompactContext {
pins,
latest_summary,
})
}
fn resolved_active_pins(
&self,
scope: &ScopeIds,
now: Timestamp,
) -> ServiceResult<Vec<ResolvedPin>> {
let pins = self.store.list_active_pins(Some(scope), Some(now))?;
pins.into_iter()
.map(|pin| {
let content = match pin.target_type {
PinTargetType::Observation => self
.store
.get_observation_by_id(&pin.target_id)?
.map(|o| o.content),
PinTargetType::Summary => self
.store
.get_summary_by_id(&pin.target_id)?
.map(|s| s.content),
};
Ok(ResolvedPin {
note: pin.reason,
content,
})
})
.collect()
}
}
fn now_ms() -> Timestamp {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_millis() as Timestamp
}