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 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 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 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 ¤t.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!(¤t_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(¤t_ids[3]));
1202 assert!(changed.contains(¤t_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(¤t_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}