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(DesiredKwebBox {
660                logical_slot,
661                name: state.name.clone(),
662                content: state.canonical.content.clone(),
663            });
664        }
665    }
666
667    let additions = connection_summary_entries(&fresh.content)?
668        .into_iter()
669        .filter(|entry| seen.insert(entry.id.clone()))
670        .collect::<Vec<_>>();
671    let mut next_addition = 0;
672
673    while next_addition < additions.len() || boxes.is_empty() {
674        let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
675        let entries = additions[next_addition..end].to_vec();
676        let mut sequence = boxes.len() + 1;
677        let logical_slot = loop {
678            let candidate = if sequence == 1 {
679                CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
680            } else {
681                format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
682            };
683            if used_logical_slots.insert(candidate.clone()) {
684                break candidate;
685            }
686            sequence += 1;
687        };
688        let mut content = fresh.content.clone();
689        update_connection_summary_content(&mut content, &logical_slot, &entries);
690        boxes.push(DesiredKwebBox {
691            logical_slot,
692            name: fresh.name.clone(),
693            content,
694        });
695        next_addition = end;
696    }
697
698    Ok(boxes)
699}
700
701type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
702
703fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
704    journal
705        .state()
706        .tool_layouts
707        .get(KWEB_TOOL_INSTANCE)
708        .into_iter()
709        .flatten()
710        .filter_map(|box_id| {
711            let state = journal.state().box_state(*box_id)?;
712            state
713                .active
714                .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
715        })
716        .collect()
717}
718
719fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
720    journal
721        .state()
722        .tool_layouts
723        .get(KWEB_TOOL_INSTANCE)
724        .into_iter()
725        .flatten()
726        .filter_map(|box_id| {
727            let state = journal.state().box_state(*box_id)?;
728            let current = (state.name.as_str(), state.canonical.event_id);
729            let changed = previous
730                .get(box_id)
731                .map(|(name, revision)| (name.as_str(), *revision) != current)
732                .unwrap_or(true);
733            (state.active && changed).then_some(*box_id)
734        })
735        .collect()
736}
737
738fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
739    if used.insert(logical.to_owned()) {
740        return logical.to_owned();
741    }
742    let mut generation = 2_u64;
743    loop {
744        let candidate = format!("{logical}#generation-{generation}");
745        if used.insert(candidate.clone()) {
746            return candidate;
747        }
748        generation += 1;
749    }
750}
751
752fn reconcile_kweb_slots(
753    journal: &mut HistorySession,
754    recorded_at: &str,
755    desired: Vec<DesiredKwebBox>,
756) -> Result<()> {
757    let current = journal
758        .state()
759        .tools
760        .get(KWEB_TOOL_INSTANCE)
761        .cloned()
762        .unwrap_or_default();
763    let desired_by_logical = desired
764        .iter()
765        .enumerate()
766        .map(|(index, entry)| (entry.logical_slot.as_str(), index))
767        .collect::<BTreeMap<_, _>>();
768    if desired_by_logical.len() != desired.len() {
769        return Err(Error::new(
770            "Kweb box layout contains duplicate logical slots",
771        ));
772    }
773    let mut claimed = HashSet::new();
774    let mut actual_by_desired = BTreeMap::new();
775    let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
776    let mut used_actual = current
777        .slots
778        .iter()
779        .map(|slot| slot.slot.clone())
780        .collect::<HashSet<_>>();
781    for slot in &current.slots {
782        let state = journal
783            .state()
784            .box_state(slot.box_id)
785            .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
786        let logical = kweb_logical_slot(state, &slot.slot);
787        let selected = !slot.retired
788            && desired_by_logical.contains_key(logical.as_str())
789            && claimed.insert(logical.clone());
790        if selected {
791            let entry = &desired[desired_by_logical[logical.as_str()]];
792            slots.push(ToolSlotInput {
793                slot: slot.slot.clone(),
794                name: entry.name.clone(),
795                content: entry.content.clone(),
796                retired: false,
797            });
798            actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
799        } else {
800            slots.push(ToolSlotInput {
801                slot: slot.slot.clone(),
802                name: state.name.clone(),
803                content: state.canonical.content.clone(),
804                retired: slot.retired || !selected,
805            });
806        }
807    }
808    for entry in &desired {
809        if actual_by_desired.contains_key(&entry.logical_slot) {
810            continue;
811        }
812        let actual = unique_slot(&entry.logical_slot, &mut used_actual);
813        slots.push(ToolSlotInput {
814            slot: actual.clone(),
815            name: entry.name.clone(),
816            content: entry.content.clone(),
817            retired: false,
818        });
819        actual_by_desired.insert(entry.logical_slot.clone(), actual);
820    }
821    let layout_slots = desired
822        .iter()
823        .map(|entry| actual_by_desired[&entry.logical_slot].clone())
824        .collect::<Vec<_>>();
825    journal
826        .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
827        .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
828    Ok(())
829}
830
831fn canonical_node_id(value: &str) -> Result<()> {
832    value
833        .parse::<NodeId>()
834        .map(|_| ())
835        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
836}
837
838#[cfg(test)]
839mod tests {
840    use std::path::PathBuf;
841    use std::time::{SystemTime, UNIX_EPOCH};
842
843    use super::*;
844    use kcode_session_history::{
845        Config as HistoryConfig, NewSession, SessionHistory,
846        chatend::{Representation, SessionKind},
847    };
848    use serde_json::json;
849
850    fn id(index: u8) -> String {
851        NodeId::from_bytes([0, 0, 0, 0, 0, index])
852            .unwrap()
853            .to_string()
854    }
855
856    fn connection(index: u8) -> Connection {
857        Connection {
858            id: id(index),
859            short_name: format!("Node {index}"),
860            short_description: format!("Summary {index}"),
861        }
862    }
863
864    fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
865        Node {
866            id: id(index),
867            short_name: format!("Node {index}"),
868            short_description: format!("Summary {index}"),
869            long_description: format!("Long description {index}"),
870            owner: id(1),
871            fixed_connections: fixed.iter().copied().map(connection).collect(),
872            recent_connections: recent.iter().copied().map(connection).collect(),
873            objects: vec![],
874            last_modified_by: "test-model-high".into(),
875            last_modified_at: Some("2026-07-28T00:00:00Z".into()),
876        }
877    }
878
879    fn node_with_recent_description(
880        index: u8,
881        fixed: &[u8],
882        recent: &[u8],
883        description: &str,
884    ) -> Node {
885        let mut node = node(index, fixed, recent);
886        for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
887            connection.short_description = format!("{description} {connection_index}");
888        }
889        node
890    }
891
892    fn draft(index: u8, recent: &[u8]) -> NodeDraft {
893        NodeDraft {
894            short_name: format!("Node {index}"),
895            short_description: format!("Summary {index}"),
896            long_description: format!("Long description {index}"),
897            owner: id(1),
898            fixed_connections: Vec::new(),
899            recent_connections: recent.iter().map(|value| id(*value)).collect(),
900            objects: Vec::new(),
901        }
902    }
903
904    fn test_journal(label: &str) -> (PathBuf, HistorySession) {
905        let root = std::env::temp_dir().join(format!(
906            "kcode-kweb-context-{label}-{}-{}",
907            std::process::id(),
908            SystemTime::now()
909                .duration_since(UNIX_EPOCH)
910                .unwrap()
911                .as_nanos()
912        ));
913        let history = SessionHistory::open(HistoryConfig {
914            directory: root.join("sessions"),
915            completed_list: root.join("completed.jsonl"),
916            provider_cost_compatibility: None,
917        })
918        .unwrap();
919        let journal = history
920            .create_session(NewSession {
921                kind: SessionKind::Conversation,
922                created_at: "2026-07-29T00:00:00Z".into(),
923                effective_context_tokens: 10_000,
924                channel: Value::Null,
925            })
926            .unwrap();
927        (root, journal)
928    }
929
930    fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
931        journal
932            .state()
933            .tool_layouts
934            .get(KWEB_TOOL_INSTANCE)
935            .into_iter()
936            .flatten()
937            .copied()
938            .filter(|box_id| {
939                journal
940                    .state()
941                    .box_state(*box_id)
942                    .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
943                    .and_then(Value::as_str)
944                    == Some("connection-summary")
945            })
946            .collect()
947    }
948
949    #[test]
950    fn compatibility_fixed_nodes_yield_to_direct_loads() {
951        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
952        context
953            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
954            .unwrap();
955        let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
956        assert!(report.promoted_from_fixed);
957        assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
958        assert!(context.fixed_node_ids().is_empty());
959        assert_eq!(
960            context
961                .box_specs(&BTreeMap::new(), &[])
962                .unwrap()
963                .iter()
964                .map(|spec| spec.kind)
965                .collect::<Vec<_>>(),
966            vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
967        );
968    }
969
970    #[test]
971    fn default_projection_keeps_only_direct_nodes_full() {
972        let mut context = Context::new(vec![id(1)]).unwrap();
973        context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
974
975        let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
976        assert_eq!(
977            projected
978                .iter()
979                .map(|item| item.key.as_str())
980                .collect::<Vec<_>>(),
981            vec![id(1), "connection-summaries".to_owned()]
982        );
983        assert_eq!(projected[0].name, "Kweb loaded node");
984        assert!(projected[0].text.contains("Long description 1"));
985        assert_eq!(projected[1].name, "Kweb connection summaries");
986        assert!(projected[1].text.contains(&id(2)));
987        assert!(projected[1].text.contains(&id(3)));
988        assert!(!context.contains_full_node(&id(2)));
989    }
990
991    #[test]
992    fn full_node_kinds_share_one_body_format_without_active_connections() {
993        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
994        context
995            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
996            .unwrap();
997        let create = StagedCreate {
998            pending_id: "pending:1".into(),
999            data: draft(3, &[]),
1000        };
1001        let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1002        assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1003        assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1004        assert_eq!(specs[2].kind.name(), "Kweb staged node");
1005        for spec in &specs[..3] {
1006            assert!(spec.text.contains("Node ID:"));
1007            assert!(spec.text.contains("Node name:"));
1008            assert!(spec.text.contains("Node owner ID:"));
1009            assert!(spec.text.contains("Fixed connection IDs:"));
1010            assert!(spec.text.contains("Recent connection IDs:"));
1011            assert!(!spec.text.contains("Active"));
1012        }
1013        assert_eq!(
1014            specs[0].text,
1015            concat!(
1016                "Node ID: AAAAAAAB\n",
1017                "Node name: Node 1\n",
1018                "Node summary: Summary 1\n",
1019                "Node owner ID: AAAAAAAB\n",
1020                "Node long description:\n",
1021                "  Long description 1\n",
1022                "Fixed connection IDs: AAAAAAAC\n",
1023                "Recent connection IDs: none"
1024            )
1025        );
1026    }
1027
1028    #[test]
1029    fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1030        let mut context = Context::new(vec![id(1)]).unwrap();
1031        context
1032            .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1033            .unwrap();
1034        context
1035            .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1036            .unwrap();
1037        let creates = vec![StagedCreate {
1038            pending_id: "pending:1".into(),
1039            data: draft(3, &[6, 7]),
1040        }];
1041        let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1042        let connections = specs
1043            .iter()
1044            .filter(|spec| spec.kind == BoxKind::Connections)
1045            .collect::<Vec<_>>();
1046        assert_eq!(connections.len(), 1);
1047        assert_eq!(
1048            connections[0].text,
1049            format!(
1050                concat!(
1051                    "Connection summaries\n",
1052                    "{} · Node 2: Summary 2\n",
1053                    "{} · Node 4: Summary 4\n",
1054                    "{} · Node 5: Summary 5\n",
1055                    "{} · Node 8: Summary 8\n",
1056                    "{} · Node 6: Summary 6\n",
1057                    "{} · Node 7: Summary 7"
1058                ),
1059                id(2),
1060                id(4),
1061                id(5),
1062                id(8),
1063                id(6),
1064                id(7)
1065            )
1066        );
1067    }
1068
1069    #[test]
1070    fn empty_connection_projection_is_still_one_exact_box() {
1071        let mut context = Context::new(vec![id(1)]).unwrap();
1072        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1073        let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1074        assert_eq!(specs.len(), 2);
1075        assert_eq!(specs[1].kind, BoxKind::Connections);
1076        assert_eq!(specs[1].text, "Connection summaries\nNone.");
1077    }
1078
1079    #[test]
1080    fn connection_projection_rejects_missing_name_or_description() {
1081        for missing_name in [true, false] {
1082            let mut source = node(1, &[], &[2]);
1083            if missing_name {
1084                source.recent_connections[0].short_name.clear();
1085            } else {
1086                source.recent_connections[0].short_description.clear();
1087            }
1088            let mut context = Context::new(vec![id(1)]).unwrap();
1089            context.apply_load(source, Vec::new()).unwrap();
1090            assert_eq!(
1091                context
1092                    .box_specs(&BTreeMap::new(), &[])
1093                    .unwrap_err()
1094                    .to_string(),
1095                format!(
1096                    "connection summary {} must resolve to a nonempty short name and short description",
1097                    id(2)
1098                )
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn staged_updates_drive_full_text_and_recent_projection() {
1105        let mut context = Context::new(vec![id(1)]).unwrap();
1106        context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1107        let mut updates = BTreeMap::new();
1108        updates.insert(id(1), draft(9, &[3]));
1109        let specs = context.box_specs(&updates, &[]).unwrap();
1110        assert!(specs[0].text.contains("Node name: Node 9"));
1111        assert!(specs[0].staged_node.is_some());
1112        assert!(!specs.last().unwrap().text.contains(&id(2)));
1113        assert!(specs.last().unwrap().text.contains(&id(3)));
1114    }
1115
1116    #[test]
1117    fn sync_appends_fresh_connection_boxes_eight_at_a_time() {
1118        let (root, mut journal) = test_journal("connection-boxes");
1119        let mut context = Context::new(vec![id(1)]).unwrap();
1120        let initial_indices = (2..=19).collect::<Vec<_>>();
1121        context
1122            .apply_load(
1123                node_with_recent_description(1, &[], &initial_indices, "old"),
1124                Vec::new(),
1125            )
1126            .unwrap();
1127        context
1128            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1129            .unwrap();
1130
1131        let original_ids = connection_summary_box_ids(&journal);
1132        assert_eq!(
1133            original_ids
1134                .iter()
1135                .map(|box_id| {
1136                    connection_summary_entries(
1137                        &journal
1138                            .state()
1139                            .box_state(*box_id)
1140                            .unwrap()
1141                            .canonical
1142                            .content,
1143                    )
1144                    .unwrap()
1145                    .len()
1146                })
1147                .collect::<Vec<_>>(),
1148            vec![8, 8, 2]
1149        );
1150        let original_revisions = original_ids
1151            .iter()
1152            .map(|box_id| {
1153                journal
1154                    .state()
1155                    .box_state(*box_id)
1156                    .unwrap()
1157                    .canonical
1158                    .event_id
1159            })
1160            .collect::<Vec<_>>();
1161        journal
1162            .summarize_box("t2", original_ids[0], "retained first box")
1163            .unwrap();
1164        journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1165
1166        let expanded_indices = (2..=28).collect::<Vec<_>>();
1167        context
1168            .refresh([node_with_recent_description(
1169                1,
1170                &[],
1171                &expanded_indices,
1172                "new",
1173            )])
1174            .unwrap();
1175        let changed = context
1176            .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1177            .unwrap();
1178        let current_ids = connection_summary_box_ids(&journal);
1179        assert_eq!(
1180            current_ids
1181                .iter()
1182                .map(|box_id| {
1183                    connection_summary_entries(
1184                        &journal
1185                            .state()
1186                            .box_state(*box_id)
1187                            .unwrap()
1188                            .canonical
1189                            .content,
1190                    )
1191                    .unwrap()
1192                    .len()
1193                })
1194                .collect::<Vec<_>>(),
1195            vec![8, 8, 2, 8, 1]
1196        );
1197        assert_eq!(&current_ids[..3], original_ids.as_slice());
1198        assert!(!changed.contains(&original_ids[0]));
1199        assert!(!changed.contains(&original_ids[1]));
1200        assert!(!changed.contains(&original_ids[2]));
1201        assert!(changed.contains(&current_ids[3]));
1202        assert!(changed.contains(&current_ids[4]));
1203
1204        let first = journal.state().box_state(original_ids[0]).unwrap();
1205        assert_eq!(first.canonical.event_id, original_revisions[0]);
1206        assert!(matches!(
1207            first.representation,
1208            Representation::Summarized { based_on, .. } if based_on == first.canonical.event_id
1209        ));
1210        let second = journal.state().box_state(original_ids[1]).unwrap();
1211        assert_eq!(second.canonical.event_id, original_revisions[1]);
1212        assert!(matches!(
1213            second.representation,
1214            Representation::Dehydrated { based_on } if based_on == second.canonical.event_id
1215        ));
1216        let third = journal.state().box_state(original_ids[2]).unwrap();
1217        assert_eq!(third.canonical.event_id, original_revisions[2]);
1218        assert!(third.canonical.content.text.contains("old 19"));
1219        assert!(!third.canonical.content.text.contains("new 25"));
1220        assert!(!third.canonical.content.text.contains("new 19"));
1221        assert!(matches!(
1222            third.representation,
1223            Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1224        ));
1225        let fourth = journal.state().box_state(current_ids[3]).unwrap();
1226        assert!(fourth.canonical.content.text.contains("new 20"));
1227        assert!(fourth.canonical.content.text.contains("new 27"));
1228        assert!(!fourth.canonical.content.text.contains("new 28"));
1229        let fifth = journal.state().box_state(current_ids[4]).unwrap();
1230        assert!(fifth.canonical.content.text.contains("new 28"));
1231
1232        drop(journal);
1233        std::fs::remove_dir_all(root).unwrap();
1234    }
1235
1236    #[test]
1237    fn sync_preserves_empty_connection_box_when_later_summaries_arrive() {
1238        let (root, mut journal) = test_journal("empty-connection-box");
1239        let mut context = Context::new(vec![id(1)]).unwrap();
1240        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1241        context
1242            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1243            .unwrap();
1244
1245        let original_boxes = connection_summary_box_ids(&journal);
1246        assert_eq!(original_boxes.len(), 1);
1247        let original_revision = journal
1248            .state()
1249            .box_state(original_boxes[0])
1250            .unwrap()
1251            .canonical
1252            .event_id;
1253        let content = &journal
1254            .state()
1255            .box_state(original_boxes[0])
1256            .unwrap()
1257            .canonical
1258            .content;
1259        assert!(connection_summary_entries(content).unwrap().is_empty());
1260        assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
1261
1262        context.refresh([node(1, &[], &[2])]).unwrap();
1263        let changed = context
1264            .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
1265            .unwrap();
1266        let current_boxes = connection_summary_box_ids(&journal);
1267        assert_eq!(current_boxes.len(), 2);
1268        assert_eq!(current_boxes[0], original_boxes[0]);
1269        assert!(!changed.contains(&original_boxes[0]));
1270        assert!(changed.contains(&current_boxes[1]));
1271        let original = journal.state().box_state(original_boxes[0]).unwrap();
1272        assert_eq!(original.canonical.event_id, original_revision);
1273        assert!(
1274            connection_summary_entries(&original.canonical.content)
1275                .unwrap()
1276                .is_empty()
1277        );
1278        let fresh = journal.state().box_state(current_boxes[1]).unwrap();
1279        assert_eq!(
1280            connection_summary_entries(&fresh.canonical.content)
1281                .unwrap()
1282                .iter()
1283                .map(|entry| entry.id.clone())
1284                .collect::<Vec<_>>(),
1285            vec![id(2)]
1286        );
1287
1288        drop(journal);
1289        std::fs::remove_dir_all(root).unwrap();
1290    }
1291
1292    #[test]
1293    fn sync_reports_only_changed_boxes_in_projection_order() {
1294        let (root, mut journal) = test_journal("changed-boxes");
1295        let mut context = Context::new(vec![id(1)]).unwrap();
1296        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1297        let initial = context
1298            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1299            .unwrap();
1300        assert_eq!(initial.len(), 2);
1301        assert!(
1302            context
1303                .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1304                .unwrap()
1305                .is_empty()
1306        );
1307
1308        context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1309        let changed = context
1310            .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1311            .unwrap();
1312        assert_eq!(changed.len(), 2);
1313        assert_eq!(
1314            changed
1315                .iter()
1316                .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1317                .collect::<Vec<_>>(),
1318            vec!["Kweb loaded node", "Kweb connection summaries"]
1319        );
1320
1321        drop(journal);
1322        std::fs::remove_dir_all(root).unwrap();
1323    }
1324}