1use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10use std::path::{Path, PathBuf};
11
12pub const MAX_FRAGMENT_TOKENS: usize = 10_000;
14pub const MAX_FRAGMENT_BYTES: usize = MAX_FRAGMENT_TOKENS * 4; pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
16pub const MAX_FRAGMENTS_PER_CONTEXT: usize = 16;
17pub const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
18pub const MAX_INSTRUCTION_FILES: usize = 32;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub enum FragmentId {
23 Workspace,
24 Permissions,
25 Route,
26 AgentTopology,
27 SkillsTools,
28 TokenBudget,
29 ProjectInstructions,
30 Constitution,
31}
32
33impl FragmentId {
34 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Workspace => "workspace",
38 Self::Permissions => "permissions",
39 Self::Route => "route",
40 Self::AgentTopology => "agent_topology",
41 Self::SkillsTools => "skills_tools",
42 Self::TokenBudget => "token_budget",
43 Self::ProjectInstructions => "project_instructions",
44 Self::Constitution => "constitution",
45 }
46 }
47 #[must_use]
48 pub fn marker(self) -> &'static str {
49 match self {
50 Self::Workspace => "<!-- cw:ctx:workspace -->",
51 Self::Permissions => "<!-- cw:ctx:permissions -->",
52 Self::Route => "<!-- cw:ctx:route -->",
53 Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
54 Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
55 Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
56 Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
57 Self::Constitution => "<!-- cw:ctx:constitution -->",
58 }
59 }
60 #[must_use]
61 pub fn role(self) -> FragmentRole {
62 match self {
63 Self::Workspace => FragmentRole::Workspace,
64 Self::Permissions => FragmentRole::Permissions,
65 Self::Route => FragmentRole::Route,
66 Self::AgentTopology => FragmentRole::AgentTopology,
67 Self::SkillsTools => FragmentRole::SkillsTools,
68 Self::TokenBudget => FragmentRole::TokenBudget,
69 Self::ProjectInstructions => FragmentRole::ProjectInstructions,
70 Self::Constitution => FragmentRole::Constitution,
71 }
72 }
73 #[must_use]
74 pub fn all() -> &'static [FragmentId] {
75 &[
76 Self::Workspace,
77 Self::Permissions,
78 Self::Route,
79 Self::AgentTopology,
80 Self::SkillsTools,
81 Self::TokenBudget,
82 Self::ProjectInstructions,
83 Self::Constitution,
84 ]
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum FragmentRole {
90 Workspace,
91 Permissions,
92 Route,
93 AgentTopology,
94 SkillsTools,
95 TokenBudget,
96 ProjectInstructions,
97 Constitution,
98}
99
100impl FragmentRole {
101 #[must_use]
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::Workspace => "workspace",
105 Self::Permissions => "permissions",
106 Self::Route => "route",
107 Self::AgentTopology => "agent_topology",
108 Self::SkillsTools => "skills_tools",
109 Self::TokenBudget => "token_budget",
110 Self::ProjectInstructions => "project_instructions",
111 Self::Constitution => "constitution",
112 }
113 }
114}
115
116#[must_use]
117pub fn estimate_tokens(text: &str) -> usize {
118 text.len().div_ceil(4)
119}
120
121pub trait ContextFragment {
123 fn fragment_id(&self) -> FragmentId;
124 fn marker(&self) -> &'static str;
125 fn content(&self) -> &str;
126 fn matches_text(&self, haystack: &str) -> bool {
127 haystack.contains(self.marker())
128 }
129 fn tokens_est(&self) -> usize {
130 estimate_tokens(self.content())
131 }
132 fn max_bytes(&self) -> usize;
133 fn is_within_token_ceiling(&self) -> bool {
134 self.tokens_est() <= MAX_FRAGMENT_TOKENS
135 }
136 fn is_within_byte_ceiling(&self) -> bool {
137 self.content().len() <= MAX_FRAGMENT_BYTES
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct BoundedFragment {
143 pub id: FragmentId,
144 pub role: FragmentRole,
145 pub marker: &'static str,
146 pub max_bytes: usize,
147 pub content: String,
148 pub content_hash: u64,
149}
150
151impl BoundedFragment {
152 #[must_use]
153 pub fn new(id: FragmentId, raw: impl Into<String>) -> Self {
154 Self::with_max_bytes(id, raw, DEFAULT_FRAGMENT_MAX_BYTES)
155 }
156 #[must_use]
157 pub fn with_max_bytes(id: FragmentId, raw: impl Into<String>, max_bytes: usize) -> Self {
158 let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
159 let mut content = enforce_byte_cap(raw.into(), clamped_max);
160 if estimate_tokens(&content) > MAX_FRAGMENT_TOKENS {
161 content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
162 }
163 let content_hash = hash_content(&content);
164 Self {
165 id,
166 role: id.role(),
167 marker: id.marker(),
168 max_bytes: clamped_max,
169 content,
170 content_hash,
171 }
172 }
173 #[must_use]
174 pub fn project_instructions(raw: impl Into<String>) -> Self {
175 Self::with_max_bytes(FragmentId::ProjectInstructions, raw, MAX_FRAGMENT_BYTES)
176 }
177 #[must_use]
178 pub fn constitution(raw: impl Into<String>) -> Self {
179 Self::with_max_bytes(FragmentId::Constitution, raw, MAX_FRAGMENT_BYTES)
180 }
181 #[must_use]
182 pub fn render_marked(&self) -> String {
183 format!("{}\n{}", self.marker, self.content.trim_end())
184 }
185}
186
187impl ContextFragment for BoundedFragment {
188 fn fragment_id(&self) -> FragmentId {
189 self.id
190 }
191 fn marker(&self) -> &'static str {
192 self.marker
193 }
194 fn content(&self) -> &str {
195 &self.content
196 }
197 fn max_bytes(&self) -> usize {
198 self.max_bytes
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
203pub enum FragmentCapError {
204 #[error("fragment {id:?} exceeds 10K-token ceiling: {tokens} tokens ({bytes} bytes)")]
205 TokenCeiling {
206 id: FragmentId,
207 tokens: usize,
208 bytes: usize,
209 },
210 #[error("fragment {id:?} exceeds byte ceiling: {bytes} > {max} bytes")]
211 ByteCeiling {
212 id: FragmentId,
213 bytes: usize,
214 max: usize,
215 },
216 #[error("context has too many fragments: {count} > {max}")]
217 TooManyFragments { count: usize, max: usize },
218}
219
220pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> {
221 if fragment.content.len() > MAX_FRAGMENT_BYTES {
222 return Err(FragmentCapError::ByteCeiling {
223 id: fragment.id,
224 bytes: fragment.content.len(),
225 max: MAX_FRAGMENT_BYTES,
226 });
227 }
228 let tokens = estimate_tokens(&fragment.content);
229 if tokens > MAX_FRAGMENT_TOKENS {
230 return Err(FragmentCapError::TokenCeiling {
231 id: fragment.id,
232 bytes: fragment.content.len(),
233 tokens,
234 });
235 }
236 Ok(())
237}
238
239pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> {
240 if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
241 return Err(FragmentCapError::TooManyFragments {
242 count: fragments.len(),
243 max: MAX_FRAGMENTS_PER_CONTEXT,
244 });
245 }
246 for f in fragments {
247 validate_fragment(f)?;
248 }
249 Ok(())
250}
251
252pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
254 "AGENTS.md",
255 ".agents/AGENTS.md",
256 "CLAUDE.md",
257 ".claude/instructions.md",
258 ".codewhale/instructions.md",
259 ".deepseek/instructions.md",
260 ".cursorrules",
261 ".cursor/rules",
262 ".clinerules",
263 ".windsurf/rules",
264 ".gemini",
265 ".github/copilot-instructions.md",
266 ".github/muse-instructions.md",
267];
268
269pub const ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
274 ".agents/AGENTS.md",
275 ".cursorrules",
276 ".cursor/rules",
277 ".clinerules",
278 ".windsurf/rules",
279 ".gemini",
280 ".github/copilot-instructions.md",
281 ".github/muse-instructions.md",
282];
283
284fn is_symlink(p: &Path) -> bool {
285 std::fs::symlink_metadata(p)
286 .map(|m| m.file_type().is_symlink())
287 .unwrap_or(false)
288}
289fn read_capped(p: &Path) -> Option<String> {
290 let meta = std::fs::metadata(p).ok()?;
291 if !meta.is_file() {
292 return None;
293 }
294 if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
295 let mut file = std::fs::File::open(p).ok()?;
296 let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
297 use std::io::Read as _;
298 let n = file.read(&mut buf).ok()?;
299 buf.truncate(n);
300 let mut text = String::from_utf8_lossy(&buf).into_owned();
301 let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
302 while end > 0 && !text.is_char_boundary(end) {
303 end -= 1;
304 }
305 text.truncate(end);
306 let omitted = meta
307 .len()
308 .saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
309 text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
310 return Some(text);
311 }
312 let raw = std::fs::read_to_string(p).ok()?;
313 let trimmed = raw.trim();
314 if trimmed.is_empty() {
315 None
316 } else {
317 Some(trimmed.to_string())
318 }
319}
320fn collect_candidate_files(workspace: &Path, candidates: &[&str]) -> Vec<PathBuf> {
321 let mut files = Vec::new();
322 for candidate in candidates {
323 let path = workspace.join(candidate);
324 if path.is_dir() {
325 let mut dir_files = Vec::new();
326 if let Ok(entries) = std::fs::read_dir(&path) {
327 for e in entries.flatten() {
328 let p = e.path();
329 if p.is_file() && p.extension().is_some_and(|e| e == "md") && !is_symlink(&p) {
330 dir_files.push(p);
331 }
332 }
333 }
334 if let Ok(entries) = std::fs::read_dir(&path) {
335 for e in entries.flatten() {
336 let p = e.path();
337 if p.is_dir()
338 && !is_symlink(&p)
339 && let Ok(sub) = std::fs::read_dir(&p)
340 {
341 for se in sub.flatten() {
342 let sp = se.path();
343 if sp.is_file()
344 && sp.extension().is_some_and(|e| e == "md")
345 && !is_symlink(&sp)
346 {
347 dir_files.push(sp);
348 }
349 }
350 }
351 }
352 }
353 dir_files.sort();
354 let remaining = MAX_INSTRUCTION_FILES.saturating_sub(files.len());
355 dir_files.truncate(remaining);
356 files.extend(dir_files);
357 } else if path.is_file() && !is_symlink(&path) {
358 files.push(path);
359 }
360 if files.len() >= MAX_INSTRUCTION_FILES {
361 break;
362 }
363 }
364 files.truncate(MAX_INSTRUCTION_FILES);
365 files.sort();
366 files.dedup();
367 files
368}
369
370fn load_project_instruction_fragment_from_candidates(
371 workspace: &Path,
372 candidates: &[&str],
373) -> Option<BoundedFragment> {
374 let files = collect_candidate_files(workspace, candidates);
375 if files.is_empty() {
376 return None;
377 }
378 let mut sections = Vec::new();
379 for path in files {
380 if let Some(content) = read_capped(&path) {
381 let rel = path
382 .strip_prefix(workspace)
383 .unwrap_or(&path)
384 .display()
385 .to_string();
386 sections.push(format!(
387 "<project_instructions source=\"{rel}\">\n{content}\n</project_instructions>"
388 ));
389 }
390 }
391 if sections.is_empty() {
392 return None;
393 }
394 let merged = sections.join("\n\n");
395 let fragment = BoundedFragment::project_instructions(merged);
396 debug_assert!(validate_fragment(&fragment).is_ok());
397 Some(fragment)
398}
399
400pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
401 load_project_instruction_fragment_from_candidates(workspace, PROJECT_INSTRUCTION_CANDIDATES)
402}
403
404pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
408 load_project_instruction_fragment_from_candidates(
409 workspace,
410 ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES,
411 )
412}
413
414pub fn project_instructions_from_sources(
415 sources: impl IntoIterator<Item = (String, String)>,
416) -> Option<BoundedFragment> {
417 let mut sections = Vec::new();
418 for (name, content) in sources {
419 let trimmed = content.trim();
420 if trimmed.is_empty() {
421 continue;
422 }
423 let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
424 let mut end = INSTRUCTIONS_FILE_MAX_BYTES;
425 while end > 0 && !trimmed.is_char_boundary(end) {
426 end -= 1;
427 }
428 let omitted = trimmed.len() - end;
429 format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end])
430 } else {
431 trimmed.to_string()
432 };
433 sections.push(format!(
434 "<project_instructions source=\"{name}\">\n{body}\n</project_instructions>"
435 ));
436 if sections.len() >= MAX_INSTRUCTION_FILES {
437 break;
438 }
439 }
440 if sections.is_empty() {
441 return None;
442 }
443 Some(BoundedFragment::project_instructions(sections.join("\n\n")))
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
447pub struct FragmentBudgetSnapshot {
448 pub fragment_ids: Vec<String>,
449 pub fragment_markers: Vec<String>,
450 pub max_fragment_bytes: usize,
451 pub max_fragment_tokens: usize,
452 pub default_fragment_max_bytes: usize,
453 pub max_fragments_per_context: usize,
454 pub instructions_file_max_bytes: usize,
455 pub max_instruction_files: usize,
456 pub project_instruction_candidates: Vec<String>,
457}
458
459#[must_use]
460pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot {
461 FragmentBudgetSnapshot {
462 fragment_ids: FragmentId::all()
463 .iter()
464 .map(|id| id.as_str().to_string())
465 .collect(),
466 fragment_markers: FragmentId::all()
467 .iter()
468 .map(|id| id.marker().to_string())
469 .collect(),
470 max_fragment_bytes: MAX_FRAGMENT_BYTES,
471 max_fragment_tokens: MAX_FRAGMENT_TOKENS,
472 default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES,
473 max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT,
474 instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES,
475 max_instruction_files: MAX_INSTRUCTION_FILES,
476 project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES
477 .iter()
478 .map(|s| s.to_string())
479 .collect(),
480 }
481}
482
483fn hash_content(content: &str) -> u64 {
484 let mut hasher = DefaultHasher::new();
485 content.hash(&mut hasher);
486 hasher.finish()
487}
488fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
489 if max_bytes == 0 {
490 return String::new();
491 }
492 if raw.len() <= max_bytes {
493 return raw;
494 }
495 let omitted = raw.len().saturating_sub(max_bytes);
496 let marker = format!("\n[…truncated: {omitted} bytes omitted]");
497 if marker.len() >= max_bytes {
498 return marker.chars().take(max_bytes).collect();
499 }
500 let keep = max_bytes.saturating_sub(marker.len());
501 let mut end = keep;
502 while end > 0 && !raw.is_char_boundary(end) {
503 end -= 1;
504 }
505 let mut out = raw[..end].to_string();
506 out.push_str(&marker);
507 out
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use std::fs;
514 use tempfile::tempdir;
515
516 #[test]
517 fn fragment_has_matches_text_recognizer() {
518 let fragment = BoundedFragment::new(FragmentId::Workspace, "repo: /tmp/demo");
519 let rendered = fragment.render_marked();
520 assert!(fragment.matches_text(&rendered));
521 assert!(!fragment.matches_text("no marker here"));
522 assert_eq!(FragmentId::Workspace.marker(), "<!-- cw:ctx:workspace -->");
523 assert_eq!(
524 FragmentId::ProjectInstructions.marker(),
525 "<!-- cw:ctx:project_instructions -->"
526 );
527 assert_eq!(
528 FragmentId::Constitution.marker(),
529 "<!-- cw:ctx:constitution -->"
530 );
531 }
532 #[test]
533 fn all_fragment_types_go_through_bounded_module() {
534 for id in FragmentId::all() {
535 let fragment = BoundedFragment::new(*id, "hello");
536 assert_eq!(fragment.marker, id.marker());
537 assert_eq!(fragment.id, *id);
538 validate_fragment(&fragment).expect("small fragment must pass caps");
539 assert!(fragment.is_within_token_ceiling());
540 assert!(fragment.is_within_byte_ceiling());
541 }
542 }
543 #[test]
544 fn per_fragment_byte_cap_truncates_with_marker() {
545 let oversized = "x".repeat(DEFAULT_FRAGMENT_MAX_BYTES + 64);
546 let fragment = BoundedFragment::new(FragmentId::AgentTopology, oversized);
547 assert!(fragment.content.len() <= DEFAULT_FRAGMENT_MAX_BYTES);
548 assert!(fragment.content.contains("[…truncated:"));
549 validate_fragment(&fragment).expect("truncated fragment must pass caps");
550 }
551 #[test]
552 fn ten_k_token_ceiling_is_enforced() {
553 let huge = "a".repeat(MAX_FRAGMENT_BYTES + 1_000);
554 let fragment = BoundedFragment::project_instructions(huge);
555 assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
556 assert!(estimate_tokens(&fragment.content) <= MAX_FRAGMENT_TOKENS);
557 validate_fragment(&fragment).expect("capped fragment must satisfy token ceiling");
558 let also_huge = "b".repeat(MAX_FRAGMENT_BYTES + 5000);
559 let fragment = BoundedFragment::with_max_bytes(FragmentId::Workspace, also_huge, 100_000);
560 assert!(fragment.max_bytes <= MAX_FRAGMENT_BYTES);
561 assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
562 assert!(fragment.is_within_token_ceiling());
563 }
564 #[test]
565 fn injected_item_count_cap_is_enforced() {
566 let fragments: Vec<BoundedFragment> = (0..MAX_FRAGMENTS_PER_CONTEXT)
567 .map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}")))
568 .collect();
569 validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass");
570 let mut too_many = fragments.clone();
571 too_many.push(BoundedFragment::new(FragmentId::Route, "one too many"));
572 let err = validate_fragment_set(&too_many).expect_err("one over cap must fail");
573 assert!(matches!(err, FragmentCapError::TooManyFragments { .. }));
574 }
575 #[test]
576 fn project_instruction_import_is_a_typed_fragment() {
577 let dir = tempdir().expect("tempdir");
578 let ws = dir.path();
579 fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
580 fs::write(ws.join(".clinerules"), "cline: prefer functional style").expect("write cline");
581 fs::create_dir_all(ws.join(".windsurf").join("rules")).expect("mkdir windsurf");
582 fs::write(
583 ws.join(".windsurf").join("rules").join("extra.md"),
584 "# windsurf extra",
585 )
586 .expect("write windsurf");
587 fs::create_dir_all(ws.join(".github")).expect("mkdir github");
588 fs::write(
589 ws.join(".github").join("copilot-instructions.md"),
590 "# copilot says hello",
591 )
592 .expect("write copilot");
593 let fragment =
594 load_project_instruction_fragment(ws).expect("must find imported instructions");
595 assert_eq!(fragment.id, FragmentId::ProjectInstructions);
596 assert!(fragment.matches_text(&fragment.render_marked()));
597 assert!(
598 fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules")
599 );
600 validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps");
601 let from_sources = project_instructions_from_sources(vec![
602 ("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()),
603 (
604 ".cursorrules".to_string(),
605 "cursor: do the thing".to_string(),
606 ),
607 ])
608 .expect("sources");
609 assert_eq!(from_sources.id, FragmentId::ProjectInstructions);
610 assert!(from_sources.content.contains("AGENTS.md"));
611 assert!(from_sources.content.contains(".cursorrules"));
612 validate_fragment(&from_sources).expect("explicit sources must also satisfy caps");
613 }
614 #[test]
615 fn additional_project_instruction_import_does_not_duplicate_canonical_authority() {
616 let dir = tempdir().expect("tempdir");
617 let ws = dir.path();
618 fs::write(ws.join("AGENTS.md"), "canonical authority marker").expect("write agents");
619
620 assert!(
621 load_additional_project_instruction_fragment(ws).is_none(),
622 "AGENTS.md is already owned by the canonical project-context loader"
623 );
624
625 fs::write(ws.join(".cursorrules"), "additional cursor marker").expect("write cursor rules");
626 let additional = load_additional_project_instruction_fragment(ws)
627 .expect("additional rules must produce a typed fragment");
628 assert!(additional.content.contains("additional cursor marker"));
629 assert!(!additional.content.contains("canonical authority marker"));
630
631 let complete = load_project_instruction_fragment(ws)
632 .expect("complete importer must retain every supported source");
633 assert!(complete.content.contains("canonical authority marker"));
634 assert!(complete.content.contains("additional cursor marker"));
635 }
636 #[test]
637 fn fragment_budget_snapshot_is_stable() {
638 let snap = fragment_budget_snapshot();
639 assert_eq!(snap.max_fragment_tokens, 10_000);
640 assert_eq!(snap.max_fragment_bytes, 40_000);
641 assert_eq!(snap.max_fragments_per_context, 16);
642 assert_eq!(snap.default_fragment_max_bytes, 4 * 1024);
643 assert!(
644 snap.fragment_ids
645 .contains(&"project_instructions".to_string())
646 );
647 assert!(snap.fragment_ids.contains(&"constitution".to_string()));
648 assert!(
649 snap.project_instruction_candidates
650 .contains(&".cursorrules".to_string())
651 );
652 assert!(
653 snap.project_instruction_candidates
654 .contains(&".github/copilot-instructions.md".to_string())
655 );
656 assert!(
657 snap.fragment_markers
658 .contains(&"<!-- cw:ctx:project_instructions -->".to_string())
659 );
660 }
661}