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#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ProjectionItem {
71 pub key: String,
73 pub name: String,
75 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 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 let mut specs = self.node_box_specs(updates, creates)?;
252 let connections = self.projected_connection_summaries(updates, creates)?;
253 specs.push(BoxSpec {
254 logical_slot: CONNECTION_SUMMARIES_LOGICAL_SLOT.into(),
255 kind: BoxKind::Connections,
256 text: format_connection_summary_entries(&connections),
257 stored_node: None,
258 staged_node: None,
259 });
260 Ok(specs)
261 }
262
263 fn node_box_specs(
264 &self,
265 updates: &BTreeMap<String, NodeDraft>,
266 creates: &[StagedCreate],
267 ) -> Result<Vec<BoxSpec>> {
268 for id in updates.keys() {
269 if !self.contains_full_node(id) {
270 return Err(Error::new(format!(
271 "staged update targets unloaded Kweb node {id}"
272 )));
273 }
274 }
275 let mut specs = Vec::new();
276 for id in &self.loaded_node_ids {
277 specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
278 }
279 for id in &self.fixed_node_ids {
280 specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
281 }
282 for create in creates {
283 specs.push(BoxSpec {
284 logical_slot: create.pending_id.clone(),
285 kind: BoxKind::Staged,
286 text: format_node(&create.pending_id, &create.data),
287 stored_node: None,
288 staged_node: Some(create.data.clone()),
289 });
290 }
291 Ok(specs)
292 }
293
294 pub fn projection(
300 &self,
301 updates: &BTreeMap<String, NodeDraft>,
302 creates: &[StagedCreate],
303 ) -> Result<Vec<ProjectionItem>> {
304 let mut projected = self
305 .node_box_specs(updates, creates)?
306 .into_iter()
307 .map(|spec| {
308 Ok(ProjectionItem {
309 key: spec.logical_slot,
310 name: spec.kind.name().to_owned(),
311 text: spec.text,
312 })
313 })
314 .collect::<Result<Vec<_>>>()?;
315 projected.extend(
316 self.projected_connection_summaries(updates, creates)?
317 .into_iter()
318 .map(|entry| ProjectionItem {
319 key: format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{}", entry.id),
320 name: "Kweb connection summary".into(),
321 text: entry.text,
322 }),
323 );
324 Ok(projected)
325 }
326
327 pub fn sync_chatend(
333 &self,
334 journal: &mut HistorySession,
335 recorded_at: impl Into<String>,
336 updates: &BTreeMap<String, NodeDraft>,
337 creates: &[StagedCreate],
338 ) -> Result<Vec<BoxId>> {
339 let recorded_at = recorded_at.into();
340 let previous = kweb_box_versions(journal);
341 let specs = self.box_specs(updates, creates)?;
342 let mut desired = Vec::with_capacity(specs.len());
343 let mut connections = None;
344 for spec in specs {
345 let mut metadata = json!({
346 "revisionHash": revision_hash(&spec.text),
347 });
348 if let Some(node) = spec.stored_node {
349 metadata["canonicalNodeId"] = json!(node.id);
350 metadata["storedNode"] = serde_json::to_value(node).map_err(|error| {
351 Error::new(format!("serializing stored Kweb node: {error}"))
352 })?;
353 }
354 if let Some(node) = spec.staged_node {
355 metadata["staged"] = json!(true);
356 metadata["nodeData"] = serde_json::to_value(node).map_err(|error| {
357 Error::new(format!("serializing staged Kweb node: {error}"))
358 })?;
359 }
360 let mut content = BoxContent {
361 text: spec.text,
362 objects: Vec::new(),
363 metadata,
364 };
365 content.use_concise_header();
366 mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
367 let entry = DesiredKwebBox {
368 logical_slot: spec.logical_slot,
369 name: spec.kind.name().into(),
370 content,
371 };
372 if spec.kind == BoxKind::Connections {
373 if connections.replace(entry).is_some() {
374 return Err(Error::new(
375 "Kweb context produced more than one connection-summary candidate",
376 ));
377 }
378 } else {
379 desired.push(entry);
380 }
381 }
382 let connections = connections
383 .ok_or_else(|| Error::new("Kweb context produced no connection-summary candidate"))?;
384 desired.extend(desired_connection_summary_boxes(journal, connections)?);
385 reconcile_kweb_slots(journal, &recorded_at, desired)?;
386 Ok(changed_kweb_box_ids(journal, &previous))
387 }
388
389 fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
390 let node = self
391 .nodes_by_id
392 .get(id)
393 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
394 let data = update.cloned().unwrap_or_else(|| node.draft());
395 Ok(BoxSpec {
396 logical_slot: id.to_owned(),
397 kind,
398 text: format_node(id, &data),
399 stored_node: Some(node.clone()),
400 staged_node: update.cloned(),
401 })
402 }
403
404 fn projected_connection_summaries(
405 &self,
406 updates: &BTreeMap<String, NodeDraft>,
407 creates: &[StagedCreate],
408 ) -> Result<Vec<ConnectionSummaryEntry>> {
409 let creates_by_id = creates
410 .iter()
411 .map(|create| (create.pending_id.as_str(), &create.data))
412 .collect::<HashMap<_, _>>();
413 let mut summaries = HashMap::new();
414 for node in self.nodes_by_id.values() {
415 summaries.insert(
416 node.id.as_str(),
417 (node.short_name.as_str(), node.short_description.as_str()),
418 );
419 for connection in node
420 .fixed_connections
421 .iter()
422 .chain(&node.recent_connections)
423 {
424 summaries.entry(connection.id.as_str()).or_insert((
425 connection.short_name.as_str(),
426 connection.short_description.as_str(),
427 ));
428 }
429 }
430 let mut connection_ids = Vec::new();
431 let mut seen = HashSet::new();
432 for id in self.full_node_ids() {
433 let node = self
434 .nodes_by_id
435 .get(id)
436 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
437 if let Some(draft) = updates.get(id) {
438 for connection_id in draft
439 .fixed_connections
440 .iter()
441 .chain(&draft.recent_connections)
442 {
443 if seen.insert(connection_id.clone()) {
444 connection_ids.push(connection_id.clone());
445 }
446 }
447 } else {
448 for connection in node
449 .fixed_connections
450 .iter()
451 .chain(&node.recent_connections)
452 {
453 if seen.insert(connection.id.clone()) {
454 connection_ids.push(connection.id.clone());
455 }
456 }
457 }
458 }
459 for create in creates {
460 for connection_id in create
461 .data
462 .fixed_connections
463 .iter()
464 .chain(&create.data.recent_connections)
465 {
466 if seen.insert(connection_id.clone()) {
467 connection_ids.push(connection_id.clone());
468 }
469 }
470 }
471 let mut entries = Vec::with_capacity(connection_ids.len());
472 for id in connection_ids {
473 let staged_summary = updates
474 .get(&id)
475 .or_else(|| creates_by_id.get(id.as_str()).copied())
476 .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
477 let (name, description) = staged_summary
478 .or_else(|| summaries.get(id.as_str()).copied())
479 .ok_or_else(|| {
480 Error::new(format!(
481 "connection summary {id} must resolve to a nonempty short name and short description"
482 ))
483 })?;
484 if name.trim().is_empty() || description.trim().is_empty() {
485 return Err(Error::new(format!(
486 "connection summary {id} must resolve to a nonempty short name and short description"
487 )));
488 }
489 let text = format!("{id} · {name}: {description}");
490 entries.push(ConnectionSummaryEntry { id, text });
491 }
492 Ok(entries)
493 }
494
495 fn rebuild_fixed(&mut self) {
496 let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
497 if !self.load_fixed_connections {
498 self.fixed_node_ids.clear();
499 self.nodes_by_id.retain(|id, _| loaded.contains(id));
500 return;
501 }
502 let mut seen = loaded.clone();
503 let mut fixed = Vec::new();
504 for id in &self.loaded_node_ids {
505 let Some(node) = self.nodes_by_id.get(id) else {
506 continue;
507 };
508 for connection in &node.fixed_connections {
509 if self.nodes_by_id.contains_key(&connection.id)
510 && seen.insert(connection.id.clone())
511 {
512 fixed.push(connection.id.clone());
513 }
514 }
515 }
516 self.fixed_node_ids = fixed;
517 self.nodes_by_id
518 .retain(|id, _| loaded.contains(id) || seen.contains(id));
519 }
520}
521
522struct DesiredKwebBox {
523 logical_slot: String,
524 name: String,
525 content: BoxContent,
526}
527
528#[derive(Clone, Debug, Eq, PartialEq)]
529struct ConnectionSummaryEntry {
530 id: String,
531 text: String,
532}
533
534fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
535 if !content.metadata.is_object() {
536 content.metadata = json!({});
537 }
538 content.metadata["kwebLogicalSlot"] = json!(logical_slot);
539 content.metadata["kwebRole"] = json!(role);
540}
541
542fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
543 state
544 .canonical
545 .content
546 .metadata
547 .get("kwebLogicalSlot")
548 .and_then(Value::as_str)
549 .unwrap_or(actual_slot)
550 .to_owned()
551}
552
553fn connection_summary_entries(content: &BoxContent) -> Result<Vec<ConnectionSummaryEntry>> {
554 let body = content
555 .text
556 .strip_prefix("Connection summaries")
557 .ok_or_else(|| Error::new("Kweb connection-summary box has an invalid heading"))?;
558 let body = body
559 .strip_prefix('\n')
560 .ok_or_else(|| Error::new("Kweb connection-summary box has no body"))?;
561 if body == "None." {
562 return Ok(Vec::new());
563 }
564
565 let mut entries: Vec<ConnectionSummaryEntry> = Vec::new();
566 for line in body.split('\n') {
567 let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
568 let canonical = identifier.parse::<NodeId>().is_ok();
569 let pending = PendingId::parse(identifier.to_owned()).is_ok();
570 (canonical || pending).then_some(identifier)
571 });
572 if let Some(identifier) = identifier {
573 entries.push(ConnectionSummaryEntry {
574 id: identifier.to_owned(),
575 text: line.to_owned(),
576 });
577 } else {
578 let entry = entries.last_mut().ok_or_else(|| {
579 Error::new("Kweb connection-summary box starts with invalid entry text")
580 })?;
581 entry.text.push('\n');
582 entry.text.push_str(line);
583 }
584 }
585
586 if let Some(expected) = content
587 .metadata
588 .get(CONNECTION_SUMMARY_IDS_METADATA)
589 .and_then(Value::as_array)
590 {
591 let expected = expected
592 .iter()
593 .map(|value| {
594 value.as_str().ok_or_else(|| {
595 Error::new("Kweb connection-summary IDs metadata contains a non-string value")
596 })
597 })
598 .collect::<Result<Vec<_>>>()?;
599 if expected
600 != entries
601 .iter()
602 .map(|entry| entry.id.as_str())
603 .collect::<Vec<_>>()
604 {
605 return Err(Error::new(
606 "Kweb connection-summary IDs metadata does not match its canonical text",
607 ));
608 }
609 }
610 Ok(entries)
611}
612
613fn format_connection_summary_entries(entries: &[ConnectionSummaryEntry]) -> String {
614 if entries.is_empty() {
615 return "Connection summaries\nNone.".into();
616 }
617 format!(
618 "Connection summaries\n{}",
619 entries
620 .iter()
621 .map(|entry| entry.text.as_str())
622 .collect::<Vec<_>>()
623 .join("\n")
624 )
625}
626
627fn revision_hash(text: &str) -> String {
628 hex::encode(Sha256::digest(text.as_bytes()))
629}
630
631fn update_connection_summary_content(
632 content: &mut BoxContent,
633 logical_slot: &str,
634 entries: &[ConnectionSummaryEntry],
635) {
636 content.text = format_connection_summary_entries(entries);
637 mark_kweb_content(content, logical_slot, "connection-summary");
638 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
639 content.metadata[CONNECTION_SUMMARY_IDS_METADATA] = json!(
640 entries
641 .iter()
642 .map(|entry| entry.id.as_str())
643 .collect::<Vec<_>>()
644 );
645}
646
647fn desired_connection_summary_boxes(
648 journal: &HistorySession,
649 fresh: DesiredKwebBox,
650) -> Result<Vec<DesiredKwebBox>> {
651 let mut boxes = Vec::new();
652 let mut seen = HashSet::new();
653 let mut used_logical_slots = HashSet::new();
654 let fresh_entries = connection_summary_entries(&fresh.content)?;
655 let fresh_by_id = fresh_entries
656 .iter()
657 .map(|entry| (entry.id.as_str(), entry.text.as_str()))
658 .collect::<HashMap<_, _>>();
659
660 if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
661 for slot in &tool.slots {
662 let state = journal
663 .state()
664 .box_state(slot.box_id)
665 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
666 let logical_slot = kweb_logical_slot(state, &slot.slot);
667 used_logical_slots.insert(logical_slot.clone());
668 if slot.retired
669 || state
670 .canonical
671 .content
672 .metadata
673 .get("kwebRole")
674 .and_then(Value::as_str)
675 != Some("connection-summary")
676 {
677 continue;
678 }
679 let mut entries = connection_summary_entries(&state.canonical.content)?;
680 for entry in &mut entries {
681 if let Some(text) = fresh_by_id.get(entry.id.as_str()) {
682 entry.text = (*text).to_owned();
683 }
684 }
685 for entry in &entries {
686 seen.insert(entry.id.clone());
687 }
688 let mut content = state.canonical.content.clone();
689 update_connection_summary_content(&mut content, &logical_slot, &entries);
690 boxes.push(DesiredKwebBox {
691 logical_slot,
692 name: state.name.clone(),
693 content,
694 });
695 }
696 }
697
698 let additions = fresh_entries
699 .iter()
700 .filter(|entry| seen.insert(entry.id.clone()))
701 .cloned()
702 .collect::<Vec<_>>();
703 let mut next_addition = 0;
704
705 while next_addition < additions.len() || boxes.is_empty() {
706 let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
707 let entries = additions[next_addition..end].to_vec();
708 let mut sequence = boxes.len() + 1;
709 let logical_slot = loop {
710 let candidate = if sequence == 1 {
711 CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
712 } else {
713 format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
714 };
715 if used_logical_slots.insert(candidate.clone()) {
716 break candidate;
717 }
718 sequence += 1;
719 };
720 let mut content = fresh.content.clone();
721 update_connection_summary_content(&mut content, &logical_slot, &entries);
722 boxes.push(DesiredKwebBox {
723 logical_slot,
724 name: fresh.name.clone(),
725 content,
726 });
727 next_addition = end;
728 }
729
730 Ok(boxes)
731}
732
733type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
734
735fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
736 journal
737 .state()
738 .tool_layouts
739 .get(KWEB_TOOL_INSTANCE)
740 .into_iter()
741 .flatten()
742 .filter_map(|box_id| {
743 let state = journal.state().box_state(*box_id)?;
744 state
745 .active
746 .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
747 })
748 .collect()
749}
750
751fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
752 journal
753 .state()
754 .tool_layouts
755 .get(KWEB_TOOL_INSTANCE)
756 .into_iter()
757 .flatten()
758 .filter_map(|box_id| {
759 let state = journal.state().box_state(*box_id)?;
760 let current = (state.name.as_str(), state.canonical.event_id);
761 let changed = previous
762 .get(box_id)
763 .map(|(name, revision)| (name.as_str(), *revision) != current)
764 .unwrap_or(true);
765 (state.active && changed).then_some(*box_id)
766 })
767 .collect()
768}
769
770fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
771 if used.insert(logical.to_owned()) {
772 return logical.to_owned();
773 }
774 let mut generation = 2_u64;
775 loop {
776 let candidate = format!("{logical}#generation-{generation}");
777 if used.insert(candidate.clone()) {
778 return candidate;
779 }
780 generation += 1;
781 }
782}
783
784fn reconcile_kweb_slots(
785 journal: &mut HistorySession,
786 recorded_at: &str,
787 desired: Vec<DesiredKwebBox>,
788) -> Result<()> {
789 let current = journal
790 .state()
791 .tools
792 .get(KWEB_TOOL_INSTANCE)
793 .cloned()
794 .unwrap_or_default();
795 let desired_by_logical = desired
796 .iter()
797 .enumerate()
798 .map(|(index, entry)| (entry.logical_slot.as_str(), index))
799 .collect::<BTreeMap<_, _>>();
800 if desired_by_logical.len() != desired.len() {
801 return Err(Error::new(
802 "Kweb box layout contains duplicate logical slots",
803 ));
804 }
805 let mut claimed = HashSet::new();
806 let mut actual_by_desired = BTreeMap::new();
807 let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
808 let mut used_actual = current
809 .slots
810 .iter()
811 .map(|slot| slot.slot.clone())
812 .collect::<HashSet<_>>();
813 for slot in ¤t.slots {
814 let state = journal
815 .state()
816 .box_state(slot.box_id)
817 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
818 let logical = kweb_logical_slot(state, &slot.slot);
819 let selected = !slot.retired
820 && desired_by_logical.contains_key(logical.as_str())
821 && claimed.insert(logical.clone());
822 if selected {
823 let entry = &desired[desired_by_logical[logical.as_str()]];
824 slots.push(ToolSlotInput {
825 slot: slot.slot.clone(),
826 name: entry.name.clone(),
827 content: entry.content.clone(),
828 retired: false,
829 });
830 actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
831 } else {
832 slots.push(ToolSlotInput {
833 slot: slot.slot.clone(),
834 name: state.name.clone(),
835 content: state.canonical.content.clone(),
836 retired: slot.retired || !selected,
837 });
838 }
839 }
840 for entry in &desired {
841 if actual_by_desired.contains_key(&entry.logical_slot) {
842 continue;
843 }
844 let actual = unique_slot(&entry.logical_slot, &mut used_actual);
845 slots.push(ToolSlotInput {
846 slot: actual.clone(),
847 name: entry.name.clone(),
848 content: entry.content.clone(),
849 retired: false,
850 });
851 actual_by_desired.insert(entry.logical_slot.clone(), actual);
852 }
853 let layout_slots = desired
854 .iter()
855 .map(|entry| actual_by_desired[&entry.logical_slot].clone())
856 .collect::<Vec<_>>();
857 journal
858 .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
859 .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
860 Ok(())
861}
862
863fn canonical_node_id(value: &str) -> Result<()> {
864 value
865 .parse::<NodeId>()
866 .map(|_| ())
867 .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
868}
869
870#[cfg(test)]
871mod tests {
872 use std::path::PathBuf;
873 use std::time::{SystemTime, UNIX_EPOCH};
874
875 use super::*;
876 use kcode_session_history::{
877 Config as HistoryConfig, NewSession, SessionHistory,
878 chatend::{Representation, SessionKind},
879 };
880 use serde_json::json;
881
882 fn id(index: u8) -> String {
883 NodeId::from_bytes([0, 0, 0, 0, 0, index])
884 .unwrap()
885 .to_string()
886 }
887
888 fn connection(index: u8) -> Connection {
889 Connection {
890 id: id(index),
891 short_name: format!("Node {index}"),
892 short_description: format!("Summary {index}"),
893 }
894 }
895
896 fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
897 Node {
898 id: id(index),
899 short_name: format!("Node {index}"),
900 short_description: format!("Summary {index}"),
901 long_description: format!("Long description {index}"),
902 owner: id(1),
903 fixed_connections: fixed.iter().copied().map(connection).collect(),
904 recent_connections: recent.iter().copied().map(connection).collect(),
905 objects: vec![],
906 last_modified_by: "test-model-high".into(),
907 last_modified_at: Some("2026-07-28T00:00:00Z".into()),
908 }
909 }
910
911 fn node_with_recent_description(
912 index: u8,
913 fixed: &[u8],
914 recent: &[u8],
915 description: &str,
916 ) -> Node {
917 let mut node = node(index, fixed, recent);
918 for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
919 connection.short_description = format!("{description} {connection_index}");
920 }
921 node
922 }
923
924 fn draft(index: u8, recent: &[u8]) -> NodeDraft {
925 NodeDraft {
926 short_name: format!("Node {index}"),
927 short_description: format!("Summary {index}"),
928 long_description: format!("Long description {index}"),
929 owner: id(1),
930 fixed_connections: Vec::new(),
931 recent_connections: recent.iter().map(|value| id(*value)).collect(),
932 objects: Vec::new(),
933 }
934 }
935
936 fn test_journal(label: &str) -> (PathBuf, HistorySession) {
937 let root = std::env::temp_dir().join(format!(
938 "kcode-kweb-context-{label}-{}-{}",
939 std::process::id(),
940 SystemTime::now()
941 .duration_since(UNIX_EPOCH)
942 .unwrap()
943 .as_nanos()
944 ));
945 let history = SessionHistory::open(HistoryConfig {
946 directory: root.join("sessions"),
947 completed_list: root.join("completed.jsonl"),
948 provider_cost_compatibility: None,
949 })
950 .unwrap();
951 let journal = history
952 .create_session(NewSession {
953 kind: SessionKind::Conversation,
954 created_at: "2026-07-29T00:00:00Z".into(),
955 effective_context_tokens: 10_000,
956 channel: Value::Null,
957 })
958 .unwrap();
959 (root, journal)
960 }
961
962 fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
963 journal
964 .state()
965 .tool_layouts
966 .get(KWEB_TOOL_INSTANCE)
967 .into_iter()
968 .flatten()
969 .copied()
970 .filter(|box_id| {
971 journal
972 .state()
973 .box_state(*box_id)
974 .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
975 .and_then(Value::as_str)
976 == Some("connection-summary")
977 })
978 .collect()
979 }
980
981 #[test]
982 fn compatibility_fixed_nodes_yield_to_direct_loads() {
983 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
984 context
985 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
986 .unwrap();
987 let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
988 assert!(report.promoted_from_fixed);
989 assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
990 assert!(context.fixed_node_ids().is_empty());
991 assert_eq!(
992 context
993 .box_specs(&BTreeMap::new(), &[])
994 .unwrap()
995 .iter()
996 .map(|spec| spec.kind)
997 .collect::<Vec<_>>(),
998 vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
999 );
1000 }
1001
1002 #[test]
1003 fn default_projection_keeps_only_direct_nodes_full() {
1004 let mut context = Context::new(vec![id(1)]).unwrap();
1005 context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
1006
1007 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1008 assert_eq!(
1009 projected
1010 .iter()
1011 .map(|item| item.key.clone())
1012 .collect::<Vec<_>>(),
1013 vec![
1014 id(1),
1015 format!("connection-summaries:{}", id(2)),
1016 format!("connection-summaries:{}", id(3)),
1017 ]
1018 );
1019 assert_eq!(projected[0].name, "Kweb loaded node");
1020 assert!(projected[0].text.contains("Long description 1"));
1021 assert_eq!(projected[1].name, "Kweb connection summary");
1022 assert!(projected[1].text.contains(&id(2)));
1023 assert!(!projected[1].text.contains(&id(3)));
1024 assert_eq!(projected[2].name, "Kweb connection summary");
1025 assert!(projected[2].text.contains(&id(3)));
1026 assert!(!projected[2].text.contains(&id(2)));
1027 assert!(!context.contains_full_node(&id(2)));
1028 }
1029
1030 #[test]
1031 fn box_free_connection_states_change_independently() {
1032 let mut context = Context::new(vec![id(1)]).unwrap();
1033 context
1034 .apply_load(
1035 node_with_recent_description(1, &[], &[2, 3], "old"),
1036 Vec::new(),
1037 )
1038 .unwrap();
1039 let initial = context
1040 .projection(&BTreeMap::new(), &[])
1041 .unwrap()
1042 .into_iter()
1043 .map(|item| (item.key, item.text))
1044 .collect::<BTreeMap<_, _>>();
1045
1046 context
1047 .refresh([node_with_recent_description(1, &[], &[2, 3, 4], "old")])
1048 .unwrap();
1049 let expanded = context
1050 .projection(&BTreeMap::new(), &[])
1051 .unwrap()
1052 .into_iter()
1053 .map(|item| (item.key, item.text))
1054 .collect::<BTreeMap<_, _>>();
1055 assert_eq!(
1056 expanded[&format!("connection-summaries:{}", id(2))],
1057 initial[&format!("connection-summaries:{}", id(2))]
1058 );
1059 assert_eq!(
1060 expanded[&format!("connection-summaries:{}", id(3))],
1061 initial[&format!("connection-summaries:{}", id(3))]
1062 );
1063 assert!(expanded.contains_key(&format!("connection-summaries:{}", id(4))));
1064
1065 let mut changed_node = node_with_recent_description(1, &[], &[2, 3, 4], "old");
1066 changed_node.recent_connections[1].short_description = "changed 3".into();
1067 context.refresh([changed_node]).unwrap();
1068 let changed = context
1069 .projection(&BTreeMap::new(), &[])
1070 .unwrap()
1071 .into_iter()
1072 .map(|item| (item.key, item.text))
1073 .collect::<BTreeMap<_, _>>();
1074 let second = format!("connection-summaries:{}", id(2));
1075 let third = format!("connection-summaries:{}", id(3));
1076 let fourth = format!("connection-summaries:{}", id(4));
1077 assert_eq!(changed[&second], expanded[&second]);
1078 assert_ne!(changed[&third], expanded[&third]);
1079 assert_eq!(changed[&fourth], expanded[&fourth]);
1080 assert_eq!(changed[&id(1)], expanded[&id(1)]);
1081 }
1082
1083 #[test]
1084 fn full_node_kinds_share_one_body_format_without_active_connections() {
1085 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1086 context
1087 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1088 .unwrap();
1089 let create = StagedCreate {
1090 pending_id: "pending:1".into(),
1091 data: draft(3, &[]),
1092 };
1093 let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1094 assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1095 assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1096 assert_eq!(specs[2].kind.name(), "Kweb staged node");
1097 for spec in &specs[..3] {
1098 assert!(spec.text.contains("Node ID:"));
1099 assert!(spec.text.contains("Node name:"));
1100 assert!(spec.text.contains("Node owner ID:"));
1101 assert!(spec.text.contains("Fixed connection IDs:"));
1102 assert!(spec.text.contains("Recent connection IDs:"));
1103 assert!(!spec.text.contains("Active"));
1104 }
1105 assert_eq!(
1106 specs[0].text,
1107 concat!(
1108 "Node ID: AAAAAAAB\n",
1109 "Node name: Node 1\n",
1110 "Node summary: Summary 1\n",
1111 "Node owner ID: AAAAAAAB\n",
1112 "Node long description:\n",
1113 " Long description 1\n",
1114 "Fixed connection IDs: AAAAAAAC\n",
1115 "Recent connection IDs: none"
1116 )
1117 );
1118 }
1119
1120 #[test]
1121 fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1122 let mut context = Context::new(vec![id(1)]).unwrap();
1123 context
1124 .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1125 .unwrap();
1126 context
1127 .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1128 .unwrap();
1129 let creates = vec![StagedCreate {
1130 pending_id: "pending:1".into(),
1131 data: draft(3, &[6, 7]),
1132 }];
1133 let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1134 let connections = specs
1135 .iter()
1136 .filter(|spec| spec.kind == BoxKind::Connections)
1137 .collect::<Vec<_>>();
1138 assert_eq!(connections.len(), 1);
1139 assert_eq!(
1140 connections[0].text,
1141 format!(
1142 concat!(
1143 "Connection summaries\n",
1144 "{} · Node 2: Summary 2\n",
1145 "{} · Node 4: Summary 4\n",
1146 "{} · Node 5: Summary 5\n",
1147 "{} · Node 8: Summary 8\n",
1148 "{} · Node 6: Summary 6\n",
1149 "{} · Node 7: Summary 7"
1150 ),
1151 id(2),
1152 id(4),
1153 id(5),
1154 id(8),
1155 id(6),
1156 id(7)
1157 )
1158 );
1159 }
1160
1161 #[test]
1162 fn empty_connection_projection_is_still_one_exact_box() {
1163 let mut context = Context::new(vec![id(1)]).unwrap();
1164 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1165 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1166 assert_eq!(specs.len(), 2);
1167 assert_eq!(specs[1].kind, BoxKind::Connections);
1168 assert_eq!(specs[1].text, "Connection summaries\nNone.");
1169 }
1170
1171 #[test]
1172 fn connection_projection_rejects_missing_name_or_description() {
1173 for missing_name in [true, false] {
1174 let mut source = node(1, &[], &[2]);
1175 if missing_name {
1176 source.recent_connections[0].short_name.clear();
1177 } else {
1178 source.recent_connections[0].short_description.clear();
1179 }
1180 let mut context = Context::new(vec![id(1)]).unwrap();
1181 context.apply_load(source, Vec::new()).unwrap();
1182 assert_eq!(
1183 context
1184 .box_specs(&BTreeMap::new(), &[])
1185 .unwrap_err()
1186 .to_string(),
1187 format!(
1188 "connection summary {} must resolve to a nonempty short name and short description",
1189 id(2)
1190 )
1191 );
1192 }
1193 }
1194
1195 #[test]
1196 fn staged_updates_drive_full_text_and_recent_projection() {
1197 let mut context = Context::new(vec![id(1)]).unwrap();
1198 context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1199 let mut updates = BTreeMap::new();
1200 updates.insert(id(1), draft(9, &[3]));
1201 let specs = context.box_specs(&updates, &[]).unwrap();
1202 assert!(specs[0].text.contains("Node name: Node 9"));
1203 assert!(specs[0].staged_node.is_some());
1204 assert!(!specs.last().unwrap().text.contains(&id(2)));
1205 assert!(specs.last().unwrap().text.contains(&id(3)));
1206 }
1207
1208 #[test]
1209 fn sync_appends_fresh_connection_boxes_eight_at_a_time() {
1210 let (root, mut journal) = test_journal("connection-boxes");
1211 let mut context = Context::new(vec![id(1)]).unwrap();
1212 let initial_indices = (2..=19).collect::<Vec<_>>();
1213 context
1214 .apply_load(
1215 node_with_recent_description(1, &[], &initial_indices, "old"),
1216 Vec::new(),
1217 )
1218 .unwrap();
1219 context
1220 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1221 .unwrap();
1222
1223 let original_ids = connection_summary_box_ids(&journal);
1224 assert_eq!(
1225 original_ids
1226 .iter()
1227 .map(|box_id| {
1228 connection_summary_entries(
1229 &journal
1230 .state()
1231 .box_state(*box_id)
1232 .unwrap()
1233 .canonical
1234 .content,
1235 )
1236 .unwrap()
1237 .len()
1238 })
1239 .collect::<Vec<_>>(),
1240 vec![8, 8, 2]
1241 );
1242 let original_revisions = original_ids
1243 .iter()
1244 .map(|box_id| {
1245 journal
1246 .state()
1247 .box_state(*box_id)
1248 .unwrap()
1249 .canonical
1250 .event_id
1251 })
1252 .collect::<Vec<_>>();
1253 journal
1254 .summarize_box("t2", original_ids[0], "retained first box")
1255 .unwrap();
1256 journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1257
1258 let expanded_indices = (2..=28).collect::<Vec<_>>();
1259 context
1260 .refresh([node_with_recent_description(
1261 1,
1262 &[],
1263 &expanded_indices,
1264 "new",
1265 )])
1266 .unwrap();
1267 let changed = context
1268 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1269 .unwrap();
1270 let current_ids = connection_summary_box_ids(&journal);
1271 assert_eq!(
1272 current_ids
1273 .iter()
1274 .map(|box_id| {
1275 connection_summary_entries(
1276 &journal
1277 .state()
1278 .box_state(*box_id)
1279 .unwrap()
1280 .canonical
1281 .content,
1282 )
1283 .unwrap()
1284 .len()
1285 })
1286 .collect::<Vec<_>>(),
1287 vec![8, 8, 2, 8, 1]
1288 );
1289 assert_eq!(¤t_ids[..3], original_ids.as_slice());
1290 assert!(changed.contains(&original_ids[0]));
1291 assert!(changed.contains(&original_ids[1]));
1292 assert!(changed.contains(&original_ids[2]));
1293 assert!(changed.contains(¤t_ids[3]));
1294 assert!(changed.contains(¤t_ids[4]));
1295
1296 let first = journal.state().box_state(original_ids[0]).unwrap();
1297 assert_ne!(first.canonical.event_id, original_revisions[0]);
1298 assert!(first.canonical.content.text.contains("new 2"));
1299 assert!(matches!(
1300 first.representation,
1301 Representation::Summarized { based_on, .. } if based_on == original_revisions[0]
1302 ));
1303 let second = journal.state().box_state(original_ids[1]).unwrap();
1304 assert_ne!(second.canonical.event_id, original_revisions[1]);
1305 assert!(second.canonical.content.text.contains("new 10"));
1306 assert!(matches!(
1307 second.representation,
1308 Representation::Dehydrated { based_on } if based_on == original_revisions[1]
1309 ));
1310 let third = journal.state().box_state(original_ids[2]).unwrap();
1311 assert_ne!(third.canonical.event_id, original_revisions[2]);
1312 assert!(!third.canonical.content.text.contains("new 25"));
1313 assert!(third.canonical.content.text.contains("new 19"));
1314 assert!(matches!(
1315 third.representation,
1316 Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1317 ));
1318 let fourth = journal.state().box_state(current_ids[3]).unwrap();
1319 assert!(fourth.canonical.content.text.contains("new 20"));
1320 assert!(fourth.canonical.content.text.contains("new 27"));
1321 assert!(!fourth.canonical.content.text.contains("new 28"));
1322 let fifth = journal.state().box_state(current_ids[4]).unwrap();
1323 assert!(fifth.canonical.content.text.contains("new 28"));
1324
1325 drop(journal);
1326 std::fs::remove_dir_all(root).unwrap();
1327 }
1328
1329 #[test]
1330 fn sync_preserves_empty_connection_box_when_later_summaries_arrive() {
1331 let (root, mut journal) = test_journal("empty-connection-box");
1332 let mut context = Context::new(vec![id(1)]).unwrap();
1333 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1334 context
1335 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1336 .unwrap();
1337
1338 let original_boxes = connection_summary_box_ids(&journal);
1339 assert_eq!(original_boxes.len(), 1);
1340 let original_revision = journal
1341 .state()
1342 .box_state(original_boxes[0])
1343 .unwrap()
1344 .canonical
1345 .event_id;
1346 let content = &journal
1347 .state()
1348 .box_state(original_boxes[0])
1349 .unwrap()
1350 .canonical
1351 .content;
1352 assert!(connection_summary_entries(content).unwrap().is_empty());
1353 assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
1354
1355 context.refresh([node(1, &[], &[2])]).unwrap();
1356 let changed = context
1357 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
1358 .unwrap();
1359 let current_boxes = connection_summary_box_ids(&journal);
1360 assert_eq!(current_boxes.len(), 2);
1361 assert_eq!(current_boxes[0], original_boxes[0]);
1362 assert!(!changed.contains(&original_boxes[0]));
1363 assert!(changed.contains(¤t_boxes[1]));
1364 let original = journal.state().box_state(original_boxes[0]).unwrap();
1365 assert_eq!(original.canonical.event_id, original_revision);
1366 assert!(
1367 connection_summary_entries(&original.canonical.content)
1368 .unwrap()
1369 .is_empty()
1370 );
1371 let fresh = journal.state().box_state(current_boxes[1]).unwrap();
1372 assert_eq!(
1373 connection_summary_entries(&fresh.canonical.content)
1374 .unwrap()
1375 .iter()
1376 .map(|entry| entry.id.clone())
1377 .collect::<Vec<_>>(),
1378 vec![id(2)]
1379 );
1380
1381 drop(journal);
1382 std::fs::remove_dir_all(root).unwrap();
1383 }
1384
1385 #[test]
1386 fn sync_reports_only_changed_boxes_in_projection_order() {
1387 let (root, mut journal) = test_journal("changed-boxes");
1388 let mut context = Context::new(vec![id(1)]).unwrap();
1389 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1390 let initial = context
1391 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1392 .unwrap();
1393 assert_eq!(initial.len(), 2);
1394 assert!(
1395 context
1396 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1397 .unwrap()
1398 .is_empty()
1399 );
1400
1401 context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1402 let changed = context
1403 .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1404 .unwrap();
1405 assert_eq!(changed.len(), 2);
1406 assert_eq!(
1407 changed
1408 .iter()
1409 .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1410 .collect::<Vec<_>>(),
1411 vec!["Kweb loaded node", "Kweb connection summaries"]
1412 );
1413 assert!(
1414 context
1415 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1416 .unwrap()
1417 .is_empty()
1418 );
1419
1420 drop(journal);
1421 std::fs::remove_dir_all(root).unwrap();
1422 }
1423}