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
//! Session-start digest — wake up already knowing (task 38).
//!
//! Session start otherwise means a blank agent composing good recall queries
//! from nothing — and boot quality then depends on that session's
//! query-writing skill, which is the documented substrate-underuse drift
//! failure mode by construction. This materializes a single, token-budgeted
//! briefing the host can inject at session start so continuity is ambient:
//! the narrative chain head (who I am, from the last verified entry), the live
//! high-importance decisions, the open conflicts and pending triggers that
//! need attention, and when hygiene last ran.
//!
//! Everything here is assembled from primitives built across this program
//! (chain_head, get_conflicts, get_pending_triggers, last_maintenance_cycle),
//! so the digest is a thin, cheap composition — not a new subsystem.
use rusqlite::params;
use crate::error::Result;
use super::YantrikDB;
/// How much the digest pulls in. Kept small so the briefing fits a tight
/// token budget at session start.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SessionDigestConfig {
/// The append-only identity/narrative chain to read the head of.
pub narrative_namespace: Option<String>,
/// **v0.9.3 isolation scope (sol converged plan item 2).** When set, the
/// digest's CONTENT aggregates (top decisions, open conflicts + count)
/// are filtered to this namespace, so a multi-tenant host composing one
/// digest per tenant never mixes another tenant's memories in. `None`
/// keeps the original explicit-global behavior (single-tenant embedded
/// use, where the caller owns the whole database anyway). Pending
/// TRIGGERS remain global in both modes — `trigger_log` rows carry
/// engine-generated operational reasons keyed by rid, not memory text,
/// and namespace-scoping them requires a source_rids join deferred to
/// the v0.10 reliability program.
pub namespace: Option<String>,
pub max_decisions: usize,
pub max_conflicts: usize,
pub max_triggers: usize,
/// Max characters of each memory's text to include as a snippet.
pub snippet_chars: usize,
}
impl Default for SessionDigestConfig {
fn default() -> Self {
Self {
narrative_namespace: None,
namespace: None,
max_decisions: 8,
max_conflicts: 5,
max_triggers: 5,
snippet_chars: 240,
}
}
}
/// A memory rendered for the digest — just enough to orient.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DigestEntry {
pub rid: String,
pub snippet: String,
pub importance: f64,
pub created_at: f64,
pub namespace: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DigestConflict {
pub conflict_id: String,
pub conflict_type: String,
pub priority: String,
pub memory_a: String,
pub memory_b: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DigestTrigger {
pub trigger_id: String,
pub trigger_type: String,
pub urgency: f64,
pub reason: String,
}
/// The session-start briefing.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct SessionDigest {
/// Head of the narrative chain (the latest verified self-entry), if a
/// narrative namespace was configured and non-empty.
pub narrative_head: Option<DigestEntry>,
/// Live high-importance memories — the decisions worth inheriting.
pub top_decisions: Vec<DigestEntry>,
/// Open conflicts needing resolution (also their total count).
pub open_conflicts: Vec<DigestConflict>,
pub open_conflict_count: usize,
/// Pending triggers due (also their total count).
pub pending_triggers: Vec<DigestTrigger>,
pub pending_trigger_count: usize,
/// JSON summary of the last maintenance cycle, if any has run.
pub last_maintenance: Option<String>,
}
impl YantrikDB {
/// Materialize the session-start digest. One call, host-injected at boot.
pub fn session_digest(&self, config: &SessionDigestConfig) -> Result<SessionDigest> {
let mut digest = SessionDigest::default();
let snip = |s: &str, n: usize| -> String { s.chars().take(n).collect::<String>() };
// Narrative chain head — the latest verified self-entry.
if let Some(ns) = config.narrative_namespace.as_deref() {
if let Some(head) = self.chain_head(ns)? {
digest.narrative_head = Some(DigestEntry {
snippet: snip(&head.text, config.snippet_chars),
rid: head.rid,
importance: head.importance,
created_at: head.created_at,
namespace: head.namespace,
});
}
}
// Top live decisions — highest importance, most recent first. Read
// directly so we can rank by importance (list_records ranks by rid).
// v0.9.3: scoped to config.namespace when set (isolation contract).
{
let conn = self.conn();
let (sql, ns_param) = match config.namespace.as_deref() {
Some(ns) => (
"SELECT rid, text, importance, created_at, namespace FROM memories \
WHERE consolidation_status = 'active' AND importance >= 0.7 \
AND namespace = ?2 \
ORDER BY importance DESC, created_at DESC LIMIT ?1",
Some(ns.to_string()),
),
None => (
"SELECT rid, text, importance, created_at, namespace FROM memories \
WHERE consolidation_status = 'active' AND importance >= 0.7 \
ORDER BY importance DESC, created_at DESC LIMIT ?1",
None,
),
};
let mut stmt = conn.prepare(sql)?;
let map_row = |r: &rusqlite::Row| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, f64>(2)?,
r.get::<_, f64>(3)?,
r.get::<_, String>(4)?,
))
};
let rows = match &ns_param {
Some(ns) => stmt
.query_map(params![config.max_decisions as i64, ns], map_row)?
.collect::<std::result::Result<Vec<_>, _>>()?,
None => stmt
.query_map(params![config.max_decisions as i64], map_row)?
.collect::<std::result::Result<Vec<_>, _>>()?,
};
drop(stmt);
drop(conn);
for (rid, enc_text, importance, created_at, namespace) in rows {
let text = self.decrypt_text(&enc_text).unwrap_or(enc_text);
digest.top_decisions.push(DigestEntry {
snippet: snip(&text, config.snippet_chars),
rid,
importance,
created_at,
namespace,
});
}
}
// Open conflicts needing attention + total count.
// v0.9.3: when a namespace scope is set, conflicts are filtered via
// their memory_a's namespace (the conflicts table itself carries no
// namespace column; memory_a and memory_b share one by construction
// since conflict detection compares within a namespace).
match config.namespace.as_deref() {
Some(ns) => {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT c.conflict_id, c.conflict_type, c.priority, c.memory_a, c.memory_b \
FROM conflicts c \
WHERE c.status = 'open' AND EXISTS (\
SELECT 1 FROM memories m WHERE m.rid = c.memory_a AND m.namespace = ?1) \
ORDER BY c.detected_at DESC LIMIT ?2",
)?;
digest.open_conflicts = stmt
.query_map(params![ns, config.max_conflicts as i64], |r| {
Ok(DigestConflict {
conflict_id: r.get(0)?,
conflict_type: r.get(1)?,
priority: r.get(2)?,
memory_a: r.get(3)?,
memory_b: r.get(4)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
digest.open_conflict_count = conn.query_row(
"SELECT COUNT(*) FROM conflicts c \
WHERE c.status = 'open' AND EXISTS (\
SELECT 1 FROM memories m WHERE m.rid = c.memory_a AND m.namespace = ?1)",
params![ns],
|r| r.get::<_, i64>(0),
)? as usize;
}
None => {
let conflicts =
self.get_conflicts(Some("open"), None, None, None, None, config.max_conflicts)?;
digest.open_conflicts = conflicts
.into_iter()
.map(|c| DigestConflict {
conflict_id: c.conflict_id,
conflict_type: c.conflict_type,
priority: c.priority,
memory_a: c.memory_a,
memory_b: c.memory_b,
})
.collect();
digest.open_conflict_count = {
let conn = self.conn();
conn.query_row(
"SELECT COUNT(*) FROM conflicts WHERE status = 'open'",
[],
|r| r.get::<_, i64>(0),
)? as usize
};
}
}
// Pending triggers due + total count.
let triggers = crate::triggers::get_pending_triggers(self, config.max_triggers)?;
digest.pending_triggers = triggers
.into_iter()
.map(|t| DigestTrigger {
trigger_id: t.trigger_id,
trigger_type: t.trigger_type,
urgency: t.urgency,
reason: t.reason,
})
.collect();
digest.pending_trigger_count = {
let conn = self.conn();
conn.query_row(
"SELECT COUNT(*) FROM trigger_log WHERE status = 'pending'",
[],
|r| r.get::<_, i64>(0),
)? as usize
};
// When hygiene last ran.
digest.last_maintenance = self.last_maintenance_cycle()?;
Ok(digest)
}
/// Task 40 — end-of-session auto-capture. Takes an agent-provided session
/// summary and drafts candidate memories from it: atomized into facts
/// (the same segmenter as the mega-blob split), stored as low-importance
/// PROVISIONAL semantic memories (`metadata.provisional = true`,
/// `source = "session_auto_capture"`) for cheap later review / the sleep
/// cycle to consolidate. Returns the new rids.
///
/// This moves the structuring work off the agent's hot path — a session
/// that never paused to call `remember` still yields well-formed memories
/// at the end. (The summary itself is the agent's to produce; the engine
/// cannot observe the conversation.)
pub fn draft_memories_from_summary(
&self,
summary: &str,
namespace: &str,
domain: &str,
) -> Result<Vec<String>> {
// Smaller target than the mega-blob split — auto-capture wants
// granular candidate facts (one thought each) for review.
let facts = super::split::segment_into_atomic_facts(summary, 120, 60);
let meta = serde_json::json!({
"provisional": true,
"kind": "session_auto_capture",
});
let mut rids = Vec::with_capacity(facts.len());
for fact in facts {
let rid = self.record_text(
&fact,
"semantic",
0.5,
0.0,
604_800.0,
&meta,
namespace,
0.7,
domain,
"session_auto_capture",
None,
)?;
rids.push(rid);
}
Ok(rids)
}
}