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