Skip to main content

kcode_kweb_context/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5pub use kcode_kweb_context_node::{
6    Connection, Error, Node, NodeDraft, Result, StagedCreate, format_node,
7};
8use kcode_kweb_db::NodeId;
9use kcode_session_history::{
10    Session as HistorySession,
11    chatend::{BoxContent, BoxId, BoxState, EventId, PendingId, ToolSlotInput},
12};
13use serde::Serialize;
14use serde_json::{Value, json};
15use sha2::{Digest, Sha256};
16
17const KWEB_TOOL_INSTANCE: &str = "kweb";
18const CONNECTION_SUMMARIES_PER_BOX: usize = 8;
19const CONNECTION_SUMMARIES_LOGICAL_SLOT: &str = "connection-summaries";
20const CONNECTION_SUMMARY_IDS_METADATA: &str = "kwebConnectionSummaryIds";
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23enum BoxKind {
24    Loaded,
25    Fixed,
26    Staged,
27    Connections,
28}
29
30impl BoxKind {
31    pub const fn name(self) -> &'static str {
32        match self {
33            Self::Loaded => "Kweb loaded node",
34            Self::Fixed => "Kweb fixed connection",
35            Self::Staged => "Kweb staged node",
36            Self::Connections => "Kweb connection summaries",
37        }
38    }
39
40    pub const fn metadata_name(self) -> &'static str {
41        match self {
42            Self::Loaded => "loaded",
43            Self::Fixed => "fixed",
44            Self::Staged => "staged",
45            Self::Connections => "connection-summary",
46        }
47    }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51struct BoxSpec {
52    logical_slot: String,
53    kind: BoxKind,
54    text: String,
55    stored_node: Option<Node>,
56    staged_node: Option<NodeDraft>,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct LoadReport {
62    pub requested_id: String,
63    pub newly_loaded: bool,
64    pub promoted_from_fixed: bool,
65    pub new_fixed_ids: Vec<String>,
66}
67
68/// One current, effect-free provider-facing Kweb context section.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ProjectionItem {
71    /// Stable logical identity used to replace a prior rendering.
72    pub key: String,
73    /// Human-readable section name.
74    pub name: String,
75    /// Complete current section text.
76    pub text: String,
77}
78
79#[derive(Clone, Debug)]
80pub struct Context {
81    root_node_ids: Vec<String>,
82    load_fixed_connections: bool,
83    loaded_node_ids: Vec<String>,
84    fixed_node_ids: Vec<String>,
85    nodes_by_id: BTreeMap<String, Node>,
86}
87
88impl Context {
89    pub fn new(root_node_ids: Vec<String>) -> Result<Self> {
90        Self::with_fixed_connections(root_node_ids, false)
91    }
92
93    /// Creates Kweb context with optional legacy full fixed-node loading.
94    pub fn with_fixed_connections(
95        root_node_ids: Vec<String>,
96        load_fixed_connections: bool,
97    ) -> Result<Self> {
98        if root_node_ids.is_empty() {
99            return Err(Error::new("Kweb context requires at least one root node"));
100        }
101        let mut seen = HashSet::new();
102        for id in &root_node_ids {
103            canonical_node_id(id)?;
104            if !seen.insert(id.clone()) {
105                return Err(Error::new("Kweb root node IDs must be distinct"));
106            }
107        }
108        Ok(Self {
109            root_node_ids,
110            load_fixed_connections,
111            loaded_node_ids: Vec::new(),
112            fixed_node_ids: Vec::new(),
113            nodes_by_id: BTreeMap::new(),
114        })
115    }
116
117    pub fn root_node_ids(&self) -> &[String] {
118        &self.root_node_ids
119    }
120
121    pub fn loaded_node_ids(&self) -> &[String] {
122        &self.loaded_node_ids
123    }
124
125    pub fn fixed_node_ids(&self) -> &[String] {
126        &self.fixed_node_ids
127    }
128
129    pub const fn loads_fixed_connections(&self) -> bool {
130        self.load_fixed_connections
131    }
132
133    pub fn full_node_ids(&self) -> Vec<&str> {
134        self.loaded_node_ids
135            .iter()
136            .chain(&self.fixed_node_ids)
137            .map(String::as_str)
138            .collect()
139    }
140
141    pub fn contains_full_node(&self, id: &str) -> bool {
142        self.loaded_node_ids.iter().any(|candidate| candidate == id)
143            || self.fixed_node_ids.iter().any(|candidate| candidate == id)
144    }
145
146    pub fn node(&self, id: &str) -> Option<&Node> {
147        self.nodes_by_id.get(id)
148    }
149
150    pub fn apply_load(&mut self, requested: Node, fixed: Vec<Node>) -> Result<LoadReport> {
151        let requested_id = requested.id.clone();
152        let was_loaded = self
153            .loaded_node_ids
154            .iter()
155            .any(|candidate| candidate == &requested_id);
156        let was_fixed = self
157            .fixed_node_ids
158            .iter()
159            .any(|candidate| candidate == &requested_id);
160        let previous_full = self
161            .full_node_ids()
162            .into_iter()
163            .map(str::to_owned)
164            .collect::<HashSet<_>>();
165        let expected_fixed = requested
166            .fixed_connections
167            .iter()
168            .map(|connection| connection.id.as_str())
169            .filter(|id| *id != requested_id)
170            .collect::<HashSet<_>>();
171        let provided_fixed = fixed
172            .iter()
173            .map(|node| node.id.as_str())
174            .collect::<HashSet<_>>();
175        if self.load_fixed_connections && expected_fixed != provided_fixed {
176            return Err(Error::new(format!(
177                "load for {requested_id} did not provide exactly its fixed connections"
178            )));
179        }
180        if !self.load_fixed_connections && !fixed.is_empty() {
181            return Err(Error::new(format!(
182                "load for {requested_id} provided full fixed connections while compatibility mode is disabled"
183            )));
184        }
185        self.nodes_by_id.insert(requested_id.clone(), requested);
186        if self.load_fixed_connections {
187            for node in fixed {
188                self.nodes_by_id.insert(node.id.clone(), node);
189            }
190        }
191        if !was_loaded {
192            self.loaded_node_ids.push(requested_id.clone());
193        }
194        self.rebuild_fixed();
195        let new_fixed_ids = self
196            .fixed_node_ids
197            .iter()
198            .filter(|id| !previous_full.contains(*id))
199            .cloned()
200            .collect();
201        Ok(LoadReport {
202            requested_id,
203            newly_loaded: !was_loaded && !was_fixed,
204            promoted_from_fixed: !was_loaded && was_fixed,
205            new_fixed_ids,
206        })
207    }
208
209    pub fn refresh(&mut self, nodes: impl IntoIterator<Item = Node>) -> Result<()> {
210        for node in nodes {
211            if !self.contains_full_node(&node.id) {
212                return Err(Error::new(format!(
213                    "cannot refresh unloaded Kweb node {}",
214                    node.id
215                )));
216            }
217            self.nodes_by_id.insert(node.id.clone(), node);
218        }
219        self.rebuild_fixed();
220        Ok(())
221    }
222
223    pub fn restore(
224        &mut self,
225        nodes: impl IntoIterator<Item = Node>,
226        directly_loaded: Vec<String>,
227    ) -> Result<()> {
228        self.nodes_by_id.clear();
229        for node in nodes {
230            self.nodes_by_id.insert(node.id.clone(), node);
231        }
232        let mut seen = HashSet::new();
233        self.loaded_node_ids = directly_loaded
234            .into_iter()
235            .filter(|id| self.nodes_by_id.contains_key(id) && seen.insert(id.clone()))
236            .collect();
237        if !self.nodes_by_id.is_empty() && self.loaded_node_ids.is_empty() {
238            return Err(Error::new(
239                "restored Kweb context contains nodes but no loaded node",
240            ));
241        }
242        self.rebuild_fixed();
243        Ok(())
244    }
245
246    fn box_specs(
247        &self,
248        updates: &BTreeMap<String, NodeDraft>,
249        creates: &[StagedCreate],
250    ) -> Result<Vec<BoxSpec>> {
251        for id in updates.keys() {
252            if !self.contains_full_node(id) {
253                return Err(Error::new(format!(
254                    "staged update targets unloaded Kweb node {id}"
255                )));
256            }
257        }
258        let mut specs = Vec::new();
259        for id in &self.loaded_node_ids {
260            specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
261        }
262        for id in &self.fixed_node_ids {
263            specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
264        }
265        for create in creates {
266            specs.push(BoxSpec {
267                logical_slot: create.pending_id.clone(),
268                kind: BoxKind::Staged,
269                text: format_node(&create.pending_id, &create.data),
270                stored_node: None,
271                staged_node: Some(create.data.clone()),
272            });
273        }
274        specs.push(BoxSpec {
275            logical_slot: CONNECTION_SUMMARIES_LOGICAL_SLOT.into(),
276            kind: BoxKind::Connections,
277            text: self.format_connection_summaries(updates, creates)?,
278            stored_node: None,
279            staged_node: None,
280        });
281        Ok(specs)
282    }
283
284    /// Renders the complete current Kweb projection without touching Chatend.
285    ///
286    /// Unlike [`Self::sync_chatend`], this view has no durable representation
287    /// history. It emits one current connection-summary section and is intended
288    /// for ephemeral consumers such as box-free subagents.
289    pub fn projection(
290        &self,
291        updates: &BTreeMap<String, NodeDraft>,
292        creates: &[StagedCreate],
293    ) -> Result<Vec<ProjectionItem>> {
294        self.box_specs(updates, creates)?
295            .into_iter()
296            .map(|spec| {
297                Ok(ProjectionItem {
298                    key: spec.logical_slot,
299                    name: spec.kind.name().to_owned(),
300                    text: spec.text,
301                })
302            })
303            .collect()
304    }
305
306    /// Reconcile this context's complete provider-facing projection through an
307    /// already-open durable Session History handle.
308    ///
309    /// The returned IDs are the active Kweb boxes whose name or canonical
310    /// revision changed, in current projection order.
311    pub fn sync_chatend(
312        &self,
313        journal: &mut HistorySession,
314        recorded_at: impl Into<String>,
315        updates: &BTreeMap<String, NodeDraft>,
316        creates: &[StagedCreate],
317    ) -> Result<Vec<BoxId>> {
318        let recorded_at = recorded_at.into();
319        let previous = kweb_box_versions(journal);
320        let specs = self.box_specs(updates, creates)?;
321        let mut desired = Vec::with_capacity(specs.len());
322        let mut connections = None;
323        for spec in specs {
324            let mut metadata = json!({
325                "revisionHash": revision_hash(&spec.text),
326            });
327            if let Some(node) = spec.stored_node {
328                metadata["canonicalNodeId"] = json!(node.id);
329                metadata["storedNode"] = serde_json::to_value(node).map_err(|error| {
330                    Error::new(format!("serializing stored Kweb node: {error}"))
331                })?;
332            }
333            if let Some(node) = spec.staged_node {
334                metadata["staged"] = json!(true);
335                metadata["nodeData"] = serde_json::to_value(node).map_err(|error| {
336                    Error::new(format!("serializing staged Kweb node: {error}"))
337                })?;
338            }
339            let mut content = BoxContent {
340                text: spec.text,
341                objects: Vec::new(),
342                metadata,
343            };
344            content.use_concise_header();
345            mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
346            let entry = DesiredKwebBox {
347                logical_slot: spec.logical_slot,
348                name: spec.kind.name().into(),
349                content,
350            };
351            if spec.kind == BoxKind::Connections {
352                if connections.replace(entry).is_some() {
353                    return Err(Error::new(
354                        "Kweb context produced more than one connection-summary candidate",
355                    ));
356                }
357            } else {
358                desired.push(entry);
359            }
360        }
361        let connections = connections
362            .ok_or_else(|| Error::new("Kweb context produced no connection-summary candidate"))?;
363        desired.extend(desired_connection_summary_boxes(journal, connections)?);
364        reconcile_kweb_slots(journal, &recorded_at, desired)?;
365        Ok(changed_kweb_box_ids(journal, &previous))
366    }
367
368    fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
369        let node = self
370            .nodes_by_id
371            .get(id)
372            .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
373        let data = update.cloned().unwrap_or_else(|| node.draft());
374        Ok(BoxSpec {
375            logical_slot: id.to_owned(),
376            kind,
377            text: format_node(id, &data),
378            stored_node: Some(node.clone()),
379            staged_node: update.cloned(),
380        })
381    }
382
383    fn format_connection_summaries(
384        &self,
385        updates: &BTreeMap<String, NodeDraft>,
386        creates: &[StagedCreate],
387    ) -> Result<String> {
388        let creates_by_id = creates
389            .iter()
390            .map(|create| (create.pending_id.as_str(), &create.data))
391            .collect::<HashMap<_, _>>();
392        let mut summaries = HashMap::new();
393        for node in self.nodes_by_id.values() {
394            summaries.insert(
395                node.id.as_str(),
396                (node.short_name.as_str(), node.short_description.as_str()),
397            );
398            for connection in node
399                .fixed_connections
400                .iter()
401                .chain(&node.recent_connections)
402            {
403                summaries.entry(connection.id.as_str()).or_insert((
404                    connection.short_name.as_str(),
405                    connection.short_description.as_str(),
406                ));
407            }
408        }
409        let mut connection_ids = Vec::new();
410        let mut seen = HashSet::new();
411        for id in self.full_node_ids() {
412            let node = self
413                .nodes_by_id
414                .get(id)
415                .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
416            if let Some(draft) = updates.get(id) {
417                for connection_id in draft
418                    .fixed_connections
419                    .iter()
420                    .chain(&draft.recent_connections)
421                {
422                    if seen.insert(connection_id.clone()) {
423                        connection_ids.push(connection_id.clone());
424                    }
425                }
426            } else {
427                for connection in node
428                    .fixed_connections
429                    .iter()
430                    .chain(&node.recent_connections)
431                {
432                    if seen.insert(connection.id.clone()) {
433                        connection_ids.push(connection.id.clone());
434                    }
435                }
436            }
437        }
438        for create in creates {
439            for connection_id in create
440                .data
441                .fixed_connections
442                .iter()
443                .chain(&create.data.recent_connections)
444            {
445                if seen.insert(connection_id.clone()) {
446                    connection_ids.push(connection_id.clone());
447                }
448            }
449        }
450        let mut lines = vec!["Connection summaries".to_owned()];
451        for id in connection_ids {
452            let staged_summary = updates
453                .get(&id)
454                .or_else(|| creates_by_id.get(id.as_str()).copied())
455                .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
456            let (name, description) = staged_summary
457                .or_else(|| summaries.get(id.as_str()).copied())
458                .ok_or_else(|| {
459                    Error::new(format!(
460                        "connection summary {id} must resolve to a nonempty short name and short description"
461                    ))
462                })?;
463            if name.trim().is_empty() || description.trim().is_empty() {
464                return Err(Error::new(format!(
465                    "connection summary {id} must resolve to a nonempty short name and short description"
466                )));
467            }
468            lines.push(format!("{id} · {name}: {description}"));
469        }
470        if lines.len() == 1 {
471            lines.push("None.".into());
472        }
473        Ok(lines.join("\n"))
474    }
475
476    fn rebuild_fixed(&mut self) {
477        let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
478        if !self.load_fixed_connections {
479            self.fixed_node_ids.clear();
480            self.nodes_by_id.retain(|id, _| loaded.contains(id));
481            return;
482        }
483        let mut seen = loaded.clone();
484        let mut fixed = Vec::new();
485        for id in &self.loaded_node_ids {
486            let Some(node) = self.nodes_by_id.get(id) else {
487                continue;
488            };
489            for connection in &node.fixed_connections {
490                if self.nodes_by_id.contains_key(&connection.id)
491                    && seen.insert(connection.id.clone())
492                {
493                    fixed.push(connection.id.clone());
494                }
495            }
496        }
497        self.fixed_node_ids = fixed;
498        self.nodes_by_id
499            .retain(|id, _| loaded.contains(id) || seen.contains(id));
500    }
501}
502
503struct DesiredKwebBox {
504    logical_slot: String,
505    name: String,
506    content: BoxContent,
507}
508
509#[derive(Clone, Debug, Eq, PartialEq)]
510struct ConnectionSummaryEntry {
511    id: String,
512    text: String,
513}
514
515fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
516    if !content.metadata.is_object() {
517        content.metadata = json!({});
518    }
519    content.metadata["kwebLogicalSlot"] = json!(logical_slot);
520    content.metadata["kwebRole"] = json!(role);
521}
522
523fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
524    state
525        .canonical
526        .content
527        .metadata
528        .get("kwebLogicalSlot")
529        .and_then(Value::as_str)
530        .unwrap_or(actual_slot)
531        .to_owned()
532}
533
534fn connection_summary_entries(content: &BoxContent) -> Result<Vec<ConnectionSummaryEntry>> {
535    let body = content
536        .text
537        .strip_prefix("Connection summaries")
538        .ok_or_else(|| Error::new("Kweb connection-summary box has an invalid heading"))?;
539    let body = body
540        .strip_prefix('\n')
541        .ok_or_else(|| Error::new("Kweb connection-summary box has no body"))?;
542    if body == "None." {
543        return Ok(Vec::new());
544    }
545
546    let mut entries: Vec<ConnectionSummaryEntry> = Vec::new();
547    for line in body.split('\n') {
548        let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
549            let canonical = identifier.parse::<NodeId>().is_ok();
550            let pending = PendingId::parse(identifier.to_owned()).is_ok();
551            (canonical || pending).then_some(identifier)
552        });
553        if let Some(identifier) = identifier {
554            entries.push(ConnectionSummaryEntry {
555                id: identifier.to_owned(),
556                text: line.to_owned(),
557            });
558        } else {
559            let entry = entries.last_mut().ok_or_else(|| {
560                Error::new("Kweb connection-summary box starts with invalid entry text")
561            })?;
562            entry.text.push('\n');
563            entry.text.push_str(line);
564        }
565    }
566
567    if let Some(expected) = content
568        .metadata
569        .get(CONNECTION_SUMMARY_IDS_METADATA)
570        .and_then(Value::as_array)
571    {
572        let expected = expected
573            .iter()
574            .map(|value| {
575                value.as_str().ok_or_else(|| {
576                    Error::new("Kweb connection-summary IDs metadata contains a non-string value")
577                })
578            })
579            .collect::<Result<Vec<_>>>()?;
580        if expected
581            != entries
582                .iter()
583                .map(|entry| entry.id.as_str())
584                .collect::<Vec<_>>()
585        {
586            return Err(Error::new(
587                "Kweb connection-summary IDs metadata does not match its canonical text",
588            ));
589        }
590    }
591    Ok(entries)
592}
593
594fn format_connection_summary_entries(entries: &[ConnectionSummaryEntry]) -> String {
595    if entries.is_empty() {
596        return "Connection summaries\nNone.".into();
597    }
598    format!(
599        "Connection summaries\n{}",
600        entries
601            .iter()
602            .map(|entry| entry.text.as_str())
603            .collect::<Vec<_>>()
604            .join("\n")
605    )
606}
607
608fn revision_hash(text: &str) -> String {
609    hex::encode(Sha256::digest(text.as_bytes()))
610}
611
612fn update_connection_summary_content(
613    content: &mut BoxContent,
614    logical_slot: &str,
615    entries: &[ConnectionSummaryEntry],
616) {
617    content.text = format_connection_summary_entries(entries);
618    mark_kweb_content(content, logical_slot, "connection-summary");
619    content.metadata["revisionHash"] = json!(revision_hash(&content.text));
620    content.metadata[CONNECTION_SUMMARY_IDS_METADATA] = json!(
621        entries
622            .iter()
623            .map(|entry| entry.id.as_str())
624            .collect::<Vec<_>>()
625    );
626}
627
628fn desired_connection_summary_boxes(
629    journal: &HistorySession,
630    fresh: DesiredKwebBox,
631) -> Result<Vec<DesiredKwebBox>> {
632    let mut boxes = Vec::new();
633    let mut seen = HashSet::new();
634    let mut used_logical_slots = HashSet::new();
635
636    if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
637        for slot in &tool.slots {
638            let state = journal
639                .state()
640                .box_state(slot.box_id)
641                .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
642            let logical_slot = kweb_logical_slot(state, &slot.slot);
643            used_logical_slots.insert(logical_slot.clone());
644            if slot.retired
645                || state
646                    .canonical
647                    .content
648                    .metadata
649                    .get("kwebRole")
650                    .and_then(Value::as_str)
651                    != Some("connection-summary")
652            {
653                continue;
654            }
655            let entries = connection_summary_entries(&state.canonical.content)?;
656            for entry in &entries {
657                seen.insert(entry.id.clone());
658            }
659            boxes.push((
660                DesiredKwebBox {
661                    logical_slot,
662                    name: state.name.clone(),
663                    content: state.canonical.content.clone(),
664                },
665                entries,
666            ));
667        }
668    }
669
670    let additions = connection_summary_entries(&fresh.content)?
671        .into_iter()
672        .filter(|entry| seen.insert(entry.id.clone()))
673        .collect::<Vec<_>>();
674    let mut next_addition = 0;
675
676    if let Some((last, entries)) = boxes.last_mut()
677        && entries.len() < CONNECTION_SUMMARIES_PER_BOX
678    {
679        let available = CONNECTION_SUMMARIES_PER_BOX - entries.len();
680        let end = additions.len().min(available);
681        entries.extend_from_slice(&additions[..end]);
682        next_addition = end;
683        if end > 0 {
684            update_connection_summary_content(&mut last.content, &last.logical_slot, entries);
685        }
686    }
687
688    while next_addition < additions.len() || boxes.is_empty() {
689        let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
690        let entries = additions[next_addition..end].to_vec();
691        let mut sequence = boxes.len() + 1;
692        let logical_slot = loop {
693            let candidate = if sequence == 1 {
694                CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
695            } else {
696                format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
697            };
698            if used_logical_slots.insert(candidate.clone()) {
699                break candidate;
700            }
701            sequence += 1;
702        };
703        let mut content = fresh.content.clone();
704        update_connection_summary_content(&mut content, &logical_slot, &entries);
705        boxes.push((
706            DesiredKwebBox {
707                logical_slot,
708                name: fresh.name.clone(),
709                content,
710            },
711            entries,
712        ));
713        next_addition = end;
714    }
715
716    Ok(boxes.into_iter().map(|(box_spec, _)| box_spec).collect())
717}
718
719type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
720
721fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
722    journal
723        .state()
724        .tool_layouts
725        .get(KWEB_TOOL_INSTANCE)
726        .into_iter()
727        .flatten()
728        .filter_map(|box_id| {
729            let state = journal.state().box_state(*box_id)?;
730            state
731                .active
732                .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
733        })
734        .collect()
735}
736
737fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
738    journal
739        .state()
740        .tool_layouts
741        .get(KWEB_TOOL_INSTANCE)
742        .into_iter()
743        .flatten()
744        .filter_map(|box_id| {
745            let state = journal.state().box_state(*box_id)?;
746            let current = (state.name.as_str(), state.canonical.event_id);
747            let changed = previous
748                .get(box_id)
749                .map(|(name, revision)| (name.as_str(), *revision) != current)
750                .unwrap_or(true);
751            (state.active && changed).then_some(*box_id)
752        })
753        .collect()
754}
755
756fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
757    if used.insert(logical.to_owned()) {
758        return logical.to_owned();
759    }
760    let mut generation = 2_u64;
761    loop {
762        let candidate = format!("{logical}#generation-{generation}");
763        if used.insert(candidate.clone()) {
764            return candidate;
765        }
766        generation += 1;
767    }
768}
769
770fn reconcile_kweb_slots(
771    journal: &mut HistorySession,
772    recorded_at: &str,
773    desired: Vec<DesiredKwebBox>,
774) -> Result<()> {
775    let current = journal
776        .state()
777        .tools
778        .get(KWEB_TOOL_INSTANCE)
779        .cloned()
780        .unwrap_or_default();
781    let desired_by_logical = desired
782        .iter()
783        .enumerate()
784        .map(|(index, entry)| (entry.logical_slot.as_str(), index))
785        .collect::<BTreeMap<_, _>>();
786    if desired_by_logical.len() != desired.len() {
787        return Err(Error::new(
788            "Kweb box layout contains duplicate logical slots",
789        ));
790    }
791    let mut claimed = HashSet::new();
792    let mut actual_by_desired = BTreeMap::new();
793    let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
794    let mut used_actual = current
795        .slots
796        .iter()
797        .map(|slot| slot.slot.clone())
798        .collect::<HashSet<_>>();
799    for slot in &current.slots {
800        let state = journal
801            .state()
802            .box_state(slot.box_id)
803            .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
804        let logical = kweb_logical_slot(state, &slot.slot);
805        let selected = !slot.retired
806            && desired_by_logical.contains_key(logical.as_str())
807            && claimed.insert(logical.clone());
808        if selected {
809            let entry = &desired[desired_by_logical[logical.as_str()]];
810            slots.push(ToolSlotInput {
811                slot: slot.slot.clone(),
812                name: entry.name.clone(),
813                content: entry.content.clone(),
814                retired: false,
815            });
816            actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
817        } else {
818            slots.push(ToolSlotInput {
819                slot: slot.slot.clone(),
820                name: state.name.clone(),
821                content: state.canonical.content.clone(),
822                retired: slot.retired || !selected,
823            });
824        }
825    }
826    for entry in &desired {
827        if actual_by_desired.contains_key(&entry.logical_slot) {
828            continue;
829        }
830        let actual = unique_slot(&entry.logical_slot, &mut used_actual);
831        slots.push(ToolSlotInput {
832            slot: actual.clone(),
833            name: entry.name.clone(),
834            content: entry.content.clone(),
835            retired: false,
836        });
837        actual_by_desired.insert(entry.logical_slot.clone(), actual);
838    }
839    let layout_slots = desired
840        .iter()
841        .map(|entry| actual_by_desired[&entry.logical_slot].clone())
842        .collect::<Vec<_>>();
843    journal
844        .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
845        .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
846    Ok(())
847}
848
849fn canonical_node_id(value: &str) -> Result<()> {
850    value
851        .parse::<NodeId>()
852        .map(|_| ())
853        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
854}
855
856#[cfg(test)]
857mod tests {
858    use std::path::PathBuf;
859    use std::time::{SystemTime, UNIX_EPOCH};
860
861    use super::*;
862    use kcode_session_history::{
863        Config as HistoryConfig, NewSession, SessionHistory,
864        chatend::{Representation, SessionKind},
865    };
866    use serde_json::json;
867
868    fn id(index: u8) -> String {
869        NodeId::from_bytes([0, 0, 0, 0, 0, index])
870            .unwrap()
871            .to_string()
872    }
873
874    fn connection(index: u8) -> Connection {
875        Connection {
876            id: id(index),
877            short_name: format!("Node {index}"),
878            short_description: format!("Summary {index}"),
879        }
880    }
881
882    fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
883        Node {
884            id: id(index),
885            short_name: format!("Node {index}"),
886            short_description: format!("Summary {index}"),
887            long_description: format!("Long description {index}"),
888            owner: id(1),
889            fixed_connections: fixed.iter().copied().map(connection).collect(),
890            recent_connections: recent.iter().copied().map(connection).collect(),
891            objects: vec![],
892            last_modified_by: "test-model-high".into(),
893            last_modified_at: Some("2026-07-28T00:00:00Z".into()),
894        }
895    }
896
897    fn node_with_recent_description(
898        index: u8,
899        fixed: &[u8],
900        recent: &[u8],
901        description: &str,
902    ) -> Node {
903        let mut node = node(index, fixed, recent);
904        for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
905            connection.short_description = format!("{description} {connection_index}");
906        }
907        node
908    }
909
910    fn draft(index: u8, recent: &[u8]) -> NodeDraft {
911        NodeDraft {
912            short_name: format!("Node {index}"),
913            short_description: format!("Summary {index}"),
914            long_description: format!("Long description {index}"),
915            owner: id(1),
916            fixed_connections: Vec::new(),
917            recent_connections: recent.iter().map(|value| id(*value)).collect(),
918            objects: Vec::new(),
919        }
920    }
921
922    fn test_journal(label: &str) -> (PathBuf, HistorySession) {
923        let root = std::env::temp_dir().join(format!(
924            "kcode-kweb-context-{label}-{}-{}",
925            std::process::id(),
926            SystemTime::now()
927                .duration_since(UNIX_EPOCH)
928                .unwrap()
929                .as_nanos()
930        ));
931        let history = SessionHistory::open(HistoryConfig {
932            directory: root.join("sessions"),
933            completed_list: root.join("completed.jsonl"),
934            provider_cost_compatibility: None,
935        })
936        .unwrap();
937        let journal = history
938            .create_session(NewSession {
939                kind: SessionKind::Conversation,
940                created_at: "2026-07-29T00:00:00Z".into(),
941                effective_context_tokens: 10_000,
942                channel: Value::Null,
943            })
944            .unwrap();
945        (root, journal)
946    }
947
948    fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
949        journal
950            .state()
951            .tool_layouts
952            .get(KWEB_TOOL_INSTANCE)
953            .into_iter()
954            .flatten()
955            .copied()
956            .filter(|box_id| {
957                journal
958                    .state()
959                    .box_state(*box_id)
960                    .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
961                    .and_then(Value::as_str)
962                    == Some("connection-summary")
963            })
964            .collect()
965    }
966
967    #[test]
968    fn compatibility_fixed_nodes_yield_to_direct_loads() {
969        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
970        context
971            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
972            .unwrap();
973        let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
974        assert!(report.promoted_from_fixed);
975        assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
976        assert!(context.fixed_node_ids().is_empty());
977        assert_eq!(
978            context
979                .box_specs(&BTreeMap::new(), &[])
980                .unwrap()
981                .iter()
982                .map(|spec| spec.kind)
983                .collect::<Vec<_>>(),
984            vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
985        );
986    }
987
988    #[test]
989    fn default_projection_keeps_only_direct_nodes_full() {
990        let mut context = Context::new(vec![id(1)]).unwrap();
991        context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
992
993        let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
994        assert_eq!(
995            projected
996                .iter()
997                .map(|item| item.key.as_str())
998                .collect::<Vec<_>>(),
999            vec![id(1), "connection-summaries".to_owned()]
1000        );
1001        assert_eq!(projected[0].name, "Kweb loaded node");
1002        assert!(projected[0].text.contains("Long description 1"));
1003        assert_eq!(projected[1].name, "Kweb connection summaries");
1004        assert!(projected[1].text.contains(&id(2)));
1005        assert!(projected[1].text.contains(&id(3)));
1006        assert!(!context.contains_full_node(&id(2)));
1007    }
1008
1009    #[test]
1010    fn full_node_kinds_share_one_body_format_without_active_connections() {
1011        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1012        context
1013            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1014            .unwrap();
1015        let create = StagedCreate {
1016            pending_id: "pending:1".into(),
1017            data: draft(3, &[]),
1018        };
1019        let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1020        assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1021        assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1022        assert_eq!(specs[2].kind.name(), "Kweb staged node");
1023        for spec in &specs[..3] {
1024            assert!(spec.text.contains("Node ID:"));
1025            assert!(spec.text.contains("Node name:"));
1026            assert!(spec.text.contains("Node owner ID:"));
1027            assert!(spec.text.contains("Fixed connection IDs:"));
1028            assert!(spec.text.contains("Recent connection IDs:"));
1029            assert!(!spec.text.contains("Active"));
1030        }
1031        assert_eq!(
1032            specs[0].text,
1033            concat!(
1034                "Node ID: AAAAAAAB\n",
1035                "Node name: Node 1\n",
1036                "Node summary: Summary 1\n",
1037                "Node owner ID: AAAAAAAB\n",
1038                "Node long description:\n",
1039                "  Long description 1\n",
1040                "Fixed connection IDs: AAAAAAAC\n",
1041                "Recent connection IDs: none"
1042            )
1043        );
1044    }
1045
1046    #[test]
1047    fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1048        let mut context = Context::new(vec![id(1)]).unwrap();
1049        context
1050            .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1051            .unwrap();
1052        context
1053            .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1054            .unwrap();
1055        let creates = vec![StagedCreate {
1056            pending_id: "pending:1".into(),
1057            data: draft(3, &[6, 7]),
1058        }];
1059        let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1060        let connections = specs
1061            .iter()
1062            .filter(|spec| spec.kind == BoxKind::Connections)
1063            .collect::<Vec<_>>();
1064        assert_eq!(connections.len(), 1);
1065        assert_eq!(
1066            connections[0].text,
1067            format!(
1068                concat!(
1069                    "Connection summaries\n",
1070                    "{} · Node 2: Summary 2\n",
1071                    "{} · Node 4: Summary 4\n",
1072                    "{} · Node 5: Summary 5\n",
1073                    "{} · Node 8: Summary 8\n",
1074                    "{} · Node 6: Summary 6\n",
1075                    "{} · Node 7: Summary 7"
1076                ),
1077                id(2),
1078                id(4),
1079                id(5),
1080                id(8),
1081                id(6),
1082                id(7)
1083            )
1084        );
1085    }
1086
1087    #[test]
1088    fn empty_connection_projection_is_still_one_exact_box() {
1089        let mut context = Context::new(vec![id(1)]).unwrap();
1090        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1091        let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1092        assert_eq!(specs.len(), 2);
1093        assert_eq!(specs[1].kind, BoxKind::Connections);
1094        assert_eq!(specs[1].text, "Connection summaries\nNone.");
1095    }
1096
1097    #[test]
1098    fn connection_projection_rejects_missing_name_or_description() {
1099        for missing_name in [true, false] {
1100            let mut source = node(1, &[], &[2]);
1101            if missing_name {
1102                source.recent_connections[0].short_name.clear();
1103            } else {
1104                source.recent_connections[0].short_description.clear();
1105            }
1106            let mut context = Context::new(vec![id(1)]).unwrap();
1107            context.apply_load(source, Vec::new()).unwrap();
1108            assert_eq!(
1109                context
1110                    .box_specs(&BTreeMap::new(), &[])
1111                    .unwrap_err()
1112                    .to_string(),
1113                format!(
1114                    "connection summary {} must resolve to a nonempty short name and short description",
1115                    id(2)
1116                )
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn staged_updates_drive_full_text_and_recent_projection() {
1123        let mut context = Context::new(vec![id(1)]).unwrap();
1124        context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1125        let mut updates = BTreeMap::new();
1126        updates.insert(id(1), draft(9, &[3]));
1127        let specs = context.box_specs(&updates, &[]).unwrap();
1128        assert!(specs[0].text.contains("Node name: Node 9"));
1129        assert!(specs[0].staged_node.is_some());
1130        assert!(!specs.last().unwrap().text.contains(&id(2)));
1131        assert!(specs.last().unwrap().text.contains(&id(3)));
1132    }
1133
1134    #[test]
1135    fn sync_fills_permanent_connection_boxes_eight_at_a_time() {
1136        let (root, mut journal) = test_journal("connection-boxes");
1137        let mut context = Context::new(vec![id(1)]).unwrap();
1138        let initial_indices = (2..=19).collect::<Vec<_>>();
1139        context
1140            .apply_load(
1141                node_with_recent_description(1, &[], &initial_indices, "old"),
1142                Vec::new(),
1143            )
1144            .unwrap();
1145        context
1146            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1147            .unwrap();
1148
1149        let original_ids = connection_summary_box_ids(&journal);
1150        assert_eq!(
1151            original_ids
1152                .iter()
1153                .map(|box_id| {
1154                    connection_summary_entries(
1155                        &journal
1156                            .state()
1157                            .box_state(*box_id)
1158                            .unwrap()
1159                            .canonical
1160                            .content,
1161                    )
1162                    .unwrap()
1163                    .len()
1164                })
1165                .collect::<Vec<_>>(),
1166            vec![8, 8, 2]
1167        );
1168        let original_revisions = original_ids
1169            .iter()
1170            .map(|box_id| {
1171                journal
1172                    .state()
1173                    .box_state(*box_id)
1174                    .unwrap()
1175                    .canonical
1176                    .event_id
1177            })
1178            .collect::<Vec<_>>();
1179        journal
1180            .summarize_box("t2", original_ids[0], "retained first box")
1181            .unwrap();
1182        journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1183
1184        let expanded_indices = (2..=28).collect::<Vec<_>>();
1185        context
1186            .refresh([node_with_recent_description(
1187                1,
1188                &[],
1189                &expanded_indices,
1190                "new",
1191            )])
1192            .unwrap();
1193        let changed = context
1194            .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1195            .unwrap();
1196        let current_ids = connection_summary_box_ids(&journal);
1197        assert_eq!(
1198            current_ids
1199                .iter()
1200                .map(|box_id| {
1201                    connection_summary_entries(
1202                        &journal
1203                            .state()
1204                            .box_state(*box_id)
1205                            .unwrap()
1206                            .canonical
1207                            .content,
1208                    )
1209                    .unwrap()
1210                    .len()
1211                })
1212                .collect::<Vec<_>>(),
1213            vec![8, 8, 8, 3]
1214        );
1215        assert_eq!(&current_ids[..3], original_ids.as_slice());
1216        assert!(!changed.contains(&original_ids[0]));
1217        assert!(!changed.contains(&original_ids[1]));
1218        assert!(changed.contains(&original_ids[2]));
1219        assert!(changed.contains(&current_ids[3]));
1220
1221        let first = journal.state().box_state(original_ids[0]).unwrap();
1222        assert_eq!(first.canonical.event_id, original_revisions[0]);
1223        assert!(matches!(
1224            first.representation,
1225            Representation::Summarized { based_on, .. } if based_on == first.canonical.event_id
1226        ));
1227        let second = journal.state().box_state(original_ids[1]).unwrap();
1228        assert_eq!(second.canonical.event_id, original_revisions[1]);
1229        assert!(matches!(
1230            second.representation,
1231            Representation::Dehydrated { based_on } if based_on == second.canonical.event_id
1232        ));
1233        let third = journal.state().box_state(original_ids[2]).unwrap();
1234        assert_ne!(third.canonical.event_id, original_revisions[2]);
1235        assert!(third.canonical.content.text.contains("old 19"));
1236        assert!(third.canonical.content.text.contains("new 25"));
1237        assert!(!third.canonical.content.text.contains("new 19"));
1238        assert!(matches!(
1239            third.representation,
1240            Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1241        ));
1242
1243        drop(journal);
1244        std::fs::remove_dir_all(root).unwrap();
1245    }
1246
1247    #[test]
1248    fn sync_starts_one_empty_fillable_connection_box() {
1249        let (root, mut journal) = test_journal("empty-connection-box");
1250        let mut context = Context::new(vec![id(1)]).unwrap();
1251        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1252        context
1253            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1254            .unwrap();
1255
1256        let boxes = connection_summary_box_ids(&journal);
1257        assert_eq!(boxes.len(), 1);
1258        let content = &journal
1259            .state()
1260            .box_state(boxes[0])
1261            .unwrap()
1262            .canonical
1263            .content;
1264        assert!(connection_summary_entries(content).unwrap().is_empty());
1265        assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
1266
1267        drop(journal);
1268        std::fs::remove_dir_all(root).unwrap();
1269    }
1270
1271    #[test]
1272    fn sync_reports_only_changed_boxes_in_projection_order() {
1273        let (root, mut journal) = test_journal("changed-boxes");
1274        let mut context = Context::new(vec![id(1)]).unwrap();
1275        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1276        let initial = context
1277            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1278            .unwrap();
1279        assert_eq!(initial.len(), 2);
1280        assert!(
1281            context
1282                .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1283                .unwrap()
1284                .is_empty()
1285        );
1286
1287        context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1288        let changed = context
1289            .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1290            .unwrap();
1291        assert_eq!(changed.len(), 2);
1292        assert_eq!(
1293            changed
1294                .iter()
1295                .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1296                .collect::<Vec<_>>(),
1297            vec!["Kweb loaded node", "Kweb connection summaries"]
1298        );
1299
1300        drop(journal);
1301        std::fs::remove_dir_all(root).unwrap();
1302    }
1303}