kernex-memory 0.10.0

Pluggable storage for Kernex: conversations, learning, and scheduled tasks.
Documentation
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
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

//! Public trait surface for the SQLite-backed memory store.
//!
//! Mirrors the inherent method surface that downstream consumers
//! (`kernex-runtime` composition, the sister-repo binary's REPL, future
//! CLI/HTTP/MCP) call today, plus three new soft-delete methods on the
//! `facts` table. Hard-delete inherent methods (`delete_fact`,
//! `delete_facts`) stay on `Store` for emergency cleanup tooling and are
//! deliberately NOT on the trait so the default consumer path uses
//! recoverable soft-delete.
//!
//! `Runtime::store_handle()` returns `Arc<dyn MemoryStore>` so a binary
//! consumer can share the runtime's composed `Store` instance instead of
//! opening a second SQLite connection against the same database file.

use std::sync::Arc;
use std::time::SystemTime;

use async_trait::async_trait;

use crate::error::MemoryError;
use crate::observation::{Observation, ObservationType, SaveEntry};
use crate::store::{DueTask, Store, TaskRunRecord, UsageSummary};
use crate::types::{HistoryRow, MessageRow};

/// Public trait surface over [`Store`].
///
/// Returned from `kernex-runtime::Runtime::store_handle()` as
/// `Arc<dyn MemoryStore>`. Consumers should prefer this trait over the
/// concrete `Store` type so future schema changes do not ripple into call
/// sites.
#[async_trait]
pub trait MemoryStore: Send + Sync {
    // --- conversations / messages ---

    /// Mark the active conversation for `(channel, sender_id, project)` as
    /// closed. Returns `true` if a row transitioned from active to closed.
    async fn close_current_conversation(
        &self,
        channel: &str,
        sender_id: &str,
        project: &str,
    ) -> Result<bool, MemoryError>;

    /// Aggregate counters:
    /// `(conversation_count, message_count, observation_count, fact_count)`.
    ///
    /// **Breaking change (kernex-memory 0.8.0):** prior versions returned
    /// a 3-tuple `(conversation, message, fact)`. The observation count
    /// joins the tuple at position 2; consumers must destructure four
    /// elements after the bump. Soft-deleted observations and facts are
    /// excluded; conversations and messages have no soft-delete column
    /// today and are counted whole.
    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64, i64), MemoryError>;

    /// On-disk byte size of the SQLite database file.
    async fn db_size(&self) -> Result<u64, MemoryError>;

    /// Aggregate token usage across all sessions.
    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError>;

    /// Recent closed-conversation summaries for a given channel + sender,
    /// newest first, capped at `limit`.
    async fn get_history(
        &self,
        channel: &str,
        sender_id: &str,
        limit: i64,
    ) -> Result<Vec<HistoryRow>, MemoryError>;

    /// FTS5 full-text search over user messages, excluding the live
    /// conversation. When `since` is `Some`, only rows with
    /// `timestamp >= since` are returned and `limit` applies after the
    /// recency filter.
    async fn search_messages(
        &self,
        query: &str,
        exclude_conversation_id: &str,
        sender_id: &str,
        limit: i64,
        since: Option<SystemTime>,
    ) -> Result<Vec<MessageRow>, MemoryError>;

    /// Fetch a single message row by its UUID. Returns `None` when the
    /// id is missing.
    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError>;

    // --- facts (write paths plus soft-only delete on the trait) ---

    /// Upsert a fact for `(sender_id, key)`. If the row was previously
    /// soft-deleted, this clears `deleted_at` so the value is visible
    /// again to default-filtered reads.
    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError>;

    /// Read a single active fact by `(sender_id, key)`. Returns `None` if
    /// the row is soft-deleted, missing, or never existed.
    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError>;

    /// Active (not soft-deleted) facts for `sender_id`.
    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError>;

    /// Soft-delete a single fact by setting its `deleted_at` timestamp.
    /// Returns `true` if a row transitioned from active to deleted; `false`
    /// if the row was already deleted, missing, or never existed.
    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError>;

    /// Soft-delete multiple facts. With `Some(key)`, soft-deletes that
    /// specific key. With `None`, soft-deletes every active fact for the
    /// sender. Returns the count of rows that transitioned from active to
    /// deleted.
    async fn soft_delete_facts(
        &self,
        sender_id: &str,
        key: Option<&str>,
    ) -> Result<u64, MemoryError>;

    /// Read soft-deleted facts (debug / recovery helper). Returns
    /// `(key, value, deleted_at)` rows for `sender_id`.
    async fn list_soft_deleted_facts(
        &self,
        sender_id: &str,
    ) -> Result<Vec<(String, String, String)>, MemoryError>;

    // --- observations (typed write surface introduced in 0.8.0) ---

    /// Persist a typed observation and return the saved row. Generates
    /// a fresh UUIDv4 id; sets `created_at == updated_at == now`. The
    /// DB enforces `length(title) > 0` and the seven-value `type` CHECK
    /// constraint; violations surface as `MemoryError::Sqlite`.
    async fn save_observation(&self, entry: SaveEntry) -> Result<Observation, MemoryError>;

    /// Fetch an active observation by id. Returns `None` when the id is
    /// missing OR the row is soft-deleted (CC-9 invariant). Mirrors the
    /// `get_message_by_id` shape introduced in 0.7.0.
    async fn get_observation_by_id(&self, id: &str) -> Result<Option<Observation>, MemoryError>;

    /// FTS5 search across observation title + structured fields.
    /// Optional `since` filters by `created_at >=`; optional `kind`
    /// narrows to a single type. Soft-deleted rows never appear.
    async fn search_observations(
        &self,
        query: &str,
        sender_id: &str,
        limit: i64,
        since: Option<SystemTime>,
        kind: Option<ObservationType>,
    ) -> Result<Vec<Observation>, MemoryError>;

    /// Soft-delete an observation by id. Returns `Ok(true)` on
    /// transition from active to deleted; `Ok(false)` when the row was
    /// already deleted, missing, or never existed (matches the
    /// `soft_delete_fact` contract).
    async fn soft_delete_observation(&self, id: &str) -> Result<bool, MemoryError>;

    /// Read soft-deleted observations for a sender. Recovery helper;
    /// surfaced on the trait so future tooling can offer an "undelete"
    /// command without dropping back to the inherent `Store`.
    async fn list_soft_deleted_observations(
        &self,
        sender_id: &str,
    ) -> Result<Vec<Observation>, MemoryError>;

    // --- scheduled tasks ---

    /// Insert a new scheduled task. Returns the new task id.
    #[allow(clippy::too_many_arguments)]
    async fn create_task(
        &self,
        channel: &str,
        sender_id: &str,
        reply_target: &str,
        description: &str,
        due_at: &str,
        repeat: Option<&str>,
        task_type: &str,
        project: &str,
    ) -> Result<String, MemoryError>;

    /// Pending tasks for `sender_id` as raw `(id, description, due_at,
    /// repeat, task_type, project)` rows, ordered by `due_at` ascending.
    async fn get_tasks_for_sender(
        &self,
        sender_id: &str,
    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError>;

    /// Mark a task as completed. With `Some("daily")` / `Some("weekly")` /
    /// etc., reschedules the next occurrence; with `None` or `Some("once")`,
    /// the task transitions to a terminal status.
    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError>;

    /// Record a task failure. Increments retry counter; transitions to a
    /// terminal failed status when retries exhaust. Returns `true` if the
    /// task transitioned to a terminal state.
    async fn fail_task(&self, id: &str, error: &str, max_retries: u32)
        -> Result<bool, MemoryError>;

    /// Cancel a pending task whose id starts with `id_prefix`, scoped to
    /// `sender_id`. Returns `true` if a row was cancelled.
    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError>;

    /// All pending tasks whose `due_at` is in the past.
    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError>;

    /// Atomically claim every due task (status 'pending' -> 'claimed' in a
    /// single statement) and return the claimed rows. When several pollers
    /// share a store, each due task is handed to exactly one of them; a
    /// claim abandoned by a dead claimer becomes reclaimable after a
    /// timeout. The claim is released by `complete_task` (recurring tasks
    /// return to 'pending' at the next due time) or `fail_task` (retries
    /// return to 'pending').
    async fn claim_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError>;

    /// Record one execution of a scheduled task. `status` must be
    /// `"completed"` or `"failed"`. Returns the new run id.
    #[allow(clippy::too_many_arguments)]
    async fn record_task_run(
        &self,
        task_id: &str,
        started_at: &str,
        status: &str,
        result: Option<&str>,
        error: Option<&str>,
        tokens_used: Option<u64>,
    ) -> Result<String, MemoryError>;

    /// Recorded runs for tasks whose id starts with `task_id_prefix`,
    /// newest first, capped at `limit`.
    async fn list_task_runs(
        &self,
        task_id_prefix: &str,
        limit: u32,
    ) -> Result<Vec<TaskRunRecord>, MemoryError>;
}

#[async_trait]
impl MemoryStore for Store {
    async fn close_current_conversation(
        &self,
        channel: &str,
        sender_id: &str,
        project: &str,
    ) -> Result<bool, MemoryError> {
        Store::close_current_conversation(self, channel, sender_id, project).await
    }

    async fn get_memory_stats(&self, sender_id: &str) -> Result<(i64, i64, i64, i64), MemoryError> {
        Store::get_memory_stats(self, sender_id).await
    }

    async fn db_size(&self) -> Result<u64, MemoryError> {
        Store::db_size(self).await
    }

    async fn get_total_usage(&self) -> Result<UsageSummary, MemoryError> {
        Store::get_total_usage(self).await
    }

    async fn get_history(
        &self,
        channel: &str,
        sender_id: &str,
        limit: i64,
    ) -> Result<Vec<HistoryRow>, MemoryError> {
        Store::get_history(self, channel, sender_id, limit).await
    }

    async fn search_messages(
        &self,
        query: &str,
        exclude_conversation_id: &str,
        sender_id: &str,
        limit: i64,
        since: Option<SystemTime>,
    ) -> Result<Vec<MessageRow>, MemoryError> {
        Store::search_messages(
            self,
            query,
            exclude_conversation_id,
            sender_id,
            limit,
            since,
        )
        .await
    }

    async fn get_message_by_id(&self, id: &str) -> Result<Option<MessageRow>, MemoryError> {
        Store::get_message_by_id(self, id).await
    }

    async fn store_fact(&self, sender_id: &str, key: &str, value: &str) -> Result<(), MemoryError> {
        Store::store_fact(self, sender_id, key, value).await
    }

    async fn get_fact(&self, sender_id: &str, key: &str) -> Result<Option<String>, MemoryError> {
        Store::get_fact(self, sender_id, key).await
    }

    async fn get_facts(&self, sender_id: &str) -> Result<Vec<(String, String)>, MemoryError> {
        Store::get_facts(self, sender_id).await
    }

    async fn soft_delete_fact(&self, sender_id: &str, key: &str) -> Result<bool, MemoryError> {
        Store::soft_delete_fact(self, sender_id, key).await
    }

    async fn soft_delete_facts(
        &self,
        sender_id: &str,
        key: Option<&str>,
    ) -> Result<u64, MemoryError> {
        Store::soft_delete_facts(self, sender_id, key).await
    }

    async fn list_soft_deleted_facts(
        &self,
        sender_id: &str,
    ) -> Result<Vec<(String, String, String)>, MemoryError> {
        Store::list_soft_deleted_facts(self, sender_id).await
    }

    async fn save_observation(&self, entry: SaveEntry) -> Result<Observation, MemoryError> {
        Store::save_observation(self, entry).await
    }

    async fn get_observation_by_id(&self, id: &str) -> Result<Option<Observation>, MemoryError> {
        Store::get_observation_by_id(self, id).await
    }

    async fn search_observations(
        &self,
        query: &str,
        sender_id: &str,
        limit: i64,
        since: Option<SystemTime>,
        kind: Option<ObservationType>,
    ) -> Result<Vec<Observation>, MemoryError> {
        Store::search_observations(self, query, sender_id, limit, since, kind).await
    }

    async fn soft_delete_observation(&self, id: &str) -> Result<bool, MemoryError> {
        Store::soft_delete_observation(self, id).await
    }

    async fn list_soft_deleted_observations(
        &self,
        sender_id: &str,
    ) -> Result<Vec<Observation>, MemoryError> {
        Store::list_soft_deleted_observations(self, sender_id).await
    }

    async fn create_task(
        &self,
        channel: &str,
        sender_id: &str,
        reply_target: &str,
        description: &str,
        due_at: &str,
        repeat: Option<&str>,
        task_type: &str,
        project: &str,
    ) -> Result<String, MemoryError> {
        Store::create_task(
            self,
            channel,
            sender_id,
            reply_target,
            description,
            due_at,
            repeat,
            task_type,
            project,
        )
        .await
    }

    async fn get_tasks_for_sender(
        &self,
        sender_id: &str,
    ) -> Result<Vec<(String, String, String, Option<String>, String, String)>, MemoryError> {
        Store::get_tasks_for_sender(self, sender_id).await
    }

    async fn complete_task(&self, id: &str, repeat: Option<&str>) -> Result<(), MemoryError> {
        Store::complete_task(self, id, repeat).await
    }

    async fn fail_task(
        &self,
        id: &str,
        error: &str,
        max_retries: u32,
    ) -> Result<bool, MemoryError> {
        Store::fail_task(self, id, error, max_retries).await
    }

    async fn cancel_task(&self, id_prefix: &str, sender_id: &str) -> Result<bool, MemoryError> {
        Store::cancel_task(self, id_prefix, sender_id).await
    }

    async fn get_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError> {
        Store::get_due_tasks(self).await
    }

    async fn claim_due_tasks(&self) -> Result<Vec<DueTask>, MemoryError> {
        Store::claim_due_tasks(self).await
    }

    async fn record_task_run(
        &self,
        task_id: &str,
        started_at: &str,
        status: &str,
        result: Option<&str>,
        error: Option<&str>,
        tokens_used: Option<u64>,
    ) -> Result<String, MemoryError> {
        Store::record_task_run(
            self,
            task_id,
            started_at,
            status,
            result,
            error,
            tokens_used,
        )
        .await
    }

    async fn list_task_runs(
        &self,
        task_id_prefix: &str,
        limit: u32,
    ) -> Result<Vec<TaskRunRecord>, MemoryError> {
        Store::list_task_runs(self, task_id_prefix, limit).await
    }
}

/// Expose a [`Store`] through the [`MemoryStore`] trait surface.
///
/// `Store` already implements `Clone` (its `SqlitePool` is internally
/// reference-counted); cloning here shares the same connection pool.
pub fn into_handle(store: Store) -> Arc<dyn MemoryStore> {
    Arc::new(store)
}