1use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::sync::Mutex;
27
28use crate::quoted_yes::Yes;
29use crate::PacksetClient;
30use anyhow::{bail, Context, Result};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34pub const ORIGIN_USER: &str = "user-declared";
35pub const ORIGIN_AGENT: &str = "agent-derived";
36pub const ORIGIN_TOOL: &str = "tool-echo";
37pub const ORIGIN_PEER: &str = "peer";
38
39pub const LEVEL_L0: &str = "derived";
41pub const LEVEL_EXPLICIT: &str = "explicit";
43
44#[must_use]
49pub fn admission_level(origin: &str) -> &'static str {
50 match origin {
51 ORIGIN_USER => LEVEL_EXPLICIT,
52 _ => LEVEL_L0,
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct Record {
58 id: String,
59 status: String,
61 remote: bool,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 remote_id: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 stored: Option<String>,
67 text: String,
68 kind: String,
69 #[serde(default, skip_serializing_if = "String::is_empty")]
71 origin: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 grant: Option<Yes>,
76 #[serde(default, skip_serializing_if = "String::is_empty")]
79 seat: String,
80 #[serde(default, skip_serializing_if = "String::is_empty")]
82 at: String,
83 #[serde(default, skip_serializing_if = "String::is_empty")]
85 reason: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 amendment: Option<Amendment>,
90 atom: Value,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95struct Amendment {
96 text: String,
97 seat: String,
98 at: String,
99}
100
101fn gate() -> std::sync::MutexGuard<'static, ()> {
102 static GATE: Mutex<()> = Mutex::new(());
103 GATE.lock().unwrap_or_else(|e| e.into_inner())
104}
105
106fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
107 static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
108 KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
109}
110
111fn proposals_path() -> PathBuf {
112 std::env::var_os("XDG_STATE_HOME")
113 .map(PathBuf::from)
114 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
115 .unwrap_or_else(|| PathBuf::from(".local/state"))
116 .join("ljos")
117 .join("proposals.jsonl")
118}
119
120fn imported_path() -> PathBuf {
122 proposals_path().with_file_name("proposals-imported")
123}
124
125fn http_timeout() -> std::time::Duration {
126 std::env::var("PACKSET_TIMEOUT_MS")
127 .ok()
128 .and_then(|v| v.trim().parse::<u64>().ok())
129 .filter(|ms| *ms > 0)
130 .map_or(
131 std::time::Duration::from_secs(30),
132 std::time::Duration::from_millis,
133 )
134}
135
136pub fn stamp_origin(atom: &mut Value, origin: &str) {
137 if let Some(map) = atom.as_object_mut() {
138 map.insert("origin".into(), Value::String(origin.to_string()));
139 }
140}
141
142pub fn stamp_person(atom: &mut Value) {
146 stamp_origin(atom, ORIGIN_USER);
147 atom["source"]["basis"] = Value::String(crate::Basis::Person.as_str().into());
148}
149
150fn strip_origin(atom: &mut Value) {
151 if let Some(map) = atom.as_object_mut() {
152 map.remove("origin");
153 }
154}
155
156fn origin_rejected(text: &str) -> bool {
157 let lower = text.to_lowercase();
158 lower.contains("origin")
159 && (lower.contains("unknown")
160 || lower.contains("unexpected")
161 || lower.contains("not a field")
162 || lower.contains("unrecognized"))
163}
164
165pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
168 let base = client.base().to_string();
169 let mut body = atom.clone();
170 let known = origin_cache()
171 .lock()
172 .unwrap_or_else(|e| e.into_inner())
173 .get(&base)
174 .copied();
175 if known == Some(false) {
176 strip_origin(&mut body);
177 }
178 match client.post_atom(&body) {
179 Ok(v) => {
180 if body.get("origin").is_some() {
181 origin_cache()
182 .lock()
183 .unwrap_or_else(|e| e.into_inner())
184 .insert(base, true);
185 }
186 Ok(v)
187 }
188 Err(e) => {
189 let text = e.to_string();
190 if body.get("origin").is_some() && origin_rejected(&text) {
191 origin_cache()
192 .lock()
193 .unwrap_or_else(|err| err.into_inner())
194 .insert(base, false);
195 strip_origin(&mut body);
196 return client.post_atom(&body).map_err(anyhow::Error::from);
197 }
198 Err(anyhow::Error::from(e))
199 }
200 }
201}
202
203fn read_latest() -> BTreeMap<String, Record> {
204 let bytes = crate::read_state_file(&proposals_path()).unwrap_or_default();
205 crate::complete_rows::<Record>(&bytes)
206 .into_iter()
207 .map(|rec| (rec.id.clone(), rec))
208 .collect()
209}
210
211fn append(rec: &Record) -> Result<()> {
212 let path = proposals_path();
213 if let Some(dir) = path.parent() {
214 std::fs::create_dir_all(dir)?;
215 }
216 let mut line = serde_json::to_string(rec)?;
217 line.push('\n');
218 crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
219 .with_context(|| format!("admit: cannot write {}", path.display()))
220}
221
222fn held_proposal_id(text: &str) -> Option<String> {
224 let marker = "held as proposal ";
225 let rest = text.get(text.find(marker)? + marker.len()..)?;
226 let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
227 (id.len() == 32).then_some(id)
228}
229
230pub fn write_agent(client: &PacksetClient, atom: Value) -> Result<Value> {
240 write_fitted(client, atom, true, write_agent_one)
241}
242
243fn write_fitted(
250 client: &PacksetClient,
251 atom: Value,
252 held: bool,
253 post: fn(&PacksetClient, Value) -> Result<Value>,
254) -> Result<Value> {
255 let text = atom["text"].as_str().unwrap_or("").trim().to_string();
256 if text.is_empty() {
257 crate::memory_port::keep_empty("lesson");
258 return Err(crate::policy_label::labelled(
259 "empty-input",
260 "remember: empty text is not a claim",
261 ));
262 }
263 let refused = (held && atom["kind"].as_str() != Some(packset_core::record::NOTE_KIND))
264 .then(|| crate::pack_copy::gate_refusal(&text))
265 .flatten();
266 if crate::pack_copy::reject_unsafe(&text).is_err() {
267 return post(client, atom);
268 }
269 if let Some(why) = refused.as_deref() {
270 let kind = atom["kind"].as_str().unwrap_or("lesson");
271 crate::memory_port::keep_prose(&text, kind, why);
272 }
273 match post(client, atom.clone()) {
274 Ok(mut posted) => {
275 label_prose(&mut posted, refused.as_deref());
276 Ok(posted)
277 }
278 Err(e) if refused.is_some() && !crate::note_can_wait(&e) => {
279 crate::verbatim::keep(&atom, refused.as_deref().unwrap_or(""))
280 }
281 Err(e) => Err(e),
282 }
283}
284
285fn label_prose(posted: &mut Value, warning: Option<&str>) {
287 let Some(why) = warning.filter(|why| !why.is_empty()) else {
288 return;
289 };
290 let Some(map) = posted.as_object_mut() else {
291 return;
292 };
293 let prose = map
294 .entry("prose".to_string())
295 .or_insert_with(|| Value::Object(Default::default()));
296 let Some(fields) = prose.as_object_mut() else {
297 return;
298 };
299 if fields
300 .get("warning")
301 .and_then(Value::as_str)
302 .is_some_and(|have| !have.is_empty())
303 {
304 return;
305 }
306 fields.insert("warning".into(), Value::String(why.to_string()));
307}
308
309pub(crate) fn write_agent_one(client: &PacksetClient, mut atom: Value) -> Result<Value> {
316 let text = atom["text"].as_str().unwrap_or("").trim();
317 if text.is_empty() {
318 crate::memory_port::keep_empty("lesson");
319 return Err(crate::policy_label::labelled(
320 "empty-input",
321 "remember: empty text is not a claim",
322 ));
323 }
324 stamp_origin(&mut atom, ORIGIN_AGENT);
325 atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
326 if atom["source"]["seat"]
327 .as_str()
328 .unwrap_or("")
329 .trim()
330 .is_empty()
331 {
332 let seat = proposing_seat();
333 if !seat.is_empty() {
334 atom["source"]["seat"] = Value::String(seat);
335 }
336 }
337 post_live(client, atom)
338}
339
340fn post_live(client: &PacksetClient, atom: Value) -> Result<Value> {
344 match post_kept(client, &atom) {
345 Ok(posted) => Ok(posted),
346 Err(e) => {
347 let text = format!("{e:#}");
348 let Some(id) = held_proposal_id(&text) else {
349 return Err(e);
350 };
351 let workspace = atom
352 .get("workspace")
353 .and_then(Value::as_str)
354 .filter(|w| !w.is_empty())
355 .map_or_else(|| client.workspace(), str::to_string);
356 let accepted = accept_on_writer(client, &workspace, &id)?;
357 let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT);
358 relabel(client, &workspace, accepted, origin)
359 }
360 }
361}
362
363pub fn store_labelled(client: &PacksetClient, mut atom: Value) -> Result<Value> {
374 if atom["text"].as_str().unwrap_or("").trim().is_empty() {
375 crate::memory_port::keep_empty("lesson");
376 return Err(crate::policy_label::labelled(
377 "empty-input",
378 "store: empty text is not a claim",
379 ));
380 }
381 if atom
382 .get("workspace")
383 .and_then(Value::as_str)
384 .unwrap_or("")
385 .is_empty()
386 {
387 atom["workspace"] = Value::String(client.workspace());
388 }
389 if !packset_core::atom::known_origin(atom["origin"].as_str().unwrap_or("")) {
390 stamp_origin(&mut atom, ORIGIN_AGENT);
391 }
392 let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
393 if origin != ORIGIN_USER && atom["source"]["basis"].as_str().is_none() {
394 atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
395 }
396 atom["level"] = Value::String(admission_level(&origin).to_string());
397 let held = atom["hook"] == Value::Bool(true) || origin == ORIGIN_AGENT;
403 write_fitted(client, atom, held, post_live)
404}
405
406pub const IMPORT_SLICE_MS: u64 = (crate::HOOK_WALL_BUDGET_MS - crate::HOOK_RERANK_BUDGET_MS) / 2;
413
414#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
416pub struct Imported {
417 pub stored: usize,
419 pub left: usize,
421}
422
423pub fn import_open(budget_ms: u64) -> Imported {
431 let _deadline = crate::pack_link::CommandDeadline::enter(crate::IMPORT_ADMISSION);
432 let marker = imported_path();
433 if marker.exists() || !proposals_path().exists() {
434 return Imported::default();
435 }
436 let _g = gate();
437 let open: Vec<Record> = read_latest()
438 .into_values()
439 .filter(|rec| rec.status == "open")
440 .collect();
441 let started = std::time::Instant::now();
442 let budget = std::time::Duration::from_millis(budget_ms);
443 let mut client = crate::pack().ok();
444 let mut out = Imported::default();
445 for mut rec in open {
446 let Some(live) = client.as_ref().filter(|_| started.elapsed() < budget) else {
447 out.left += 1;
448 continue;
449 };
450 let stored = if rec.remote {
451 import_held(live, &rec)
452 } else {
453 store_labelled(live, rec.atom.clone())
454 };
455 match stored {
456 Ok(posted) => {
457 rec.status = "stored".into();
458 rec.stored = posted["id"].as_str().map(str::to_string);
459 }
460 Err(e) if crate::note_can_wait(&e) => {
461 client = None;
462 out.left += 1;
463 continue;
464 }
465 Err(err) => {
466 let text = rec.atom["text"].as_str().unwrap_or("");
467 let kind = rec.atom["kind"].as_str().unwrap_or("lesson");
468 crate::memory_port::keep_prose(text, kind, &format!("legacy proposal: {err:#}"));
469 rec.status = "refused".into();
470 }
471 }
472 match append(&rec) {
473 Ok(()) if rec.status == "stored" => out.stored += 1,
474 Ok(()) => {}
475 Err(_) => out.left += 1,
476 }
477 }
478 if out.left == 0 {
479 let _ = std::fs::write(&marker, format!("{}\n", crate::now_utc()));
480 }
481 out
482}
483
484fn import_held(client: &PacksetClient, rec: &Record) -> Result<Value> {
488 let workspace = workspace_of(client, rec);
489 let remote_id = rec.remote_id.as_deref().unwrap_or(&rec.id);
490 let accepted = match accept_on_writer(client, &workspace, remote_id) {
491 Ok(accepted) => accepted,
492 Err(e) if crate::note_can_wait(&e) => return Err(e),
493 Err(_) => return store_labelled(client, rec.atom.clone()),
494 };
495 let origin = if rec.origin.is_empty() {
496 ORIGIN_AGENT
497 } else {
498 rec.origin.as_str()
499 };
500 let Some(id) = accepted["id"].as_str().filter(|id| !id.is_empty()) else {
501 bail!("import: the writer stored {remote_id} without an id");
502 };
503 update_fields(
504 client,
505 &workspace,
506 id,
507 serde_json::json!({"origin": origin, "level": admission_level(origin)}),
508 )
509}
510
511fn promote_level(atom: &mut Value) {
513 if let Some(map) = atom.as_object_mut() {
514 map.insert("level".into(), Value::String(LEVEL_EXPLICIT.to_string()));
515 }
516}
517
518fn stand_for_prompt(atom: &mut Value) {
523 promote_level(atom);
524 let mut entities = atom
525 .get("entities")
526 .and_then(Value::as_array)
527 .cloned()
528 .unwrap_or_default();
529 let standing = entities
530 .iter()
531 .any(|entity| entity.as_str() == Some("horizon:standing"));
532 entities.retain(|entity| entity.as_str() != Some("horizon:transient"));
533 if !standing {
534 entities.push(Value::String("horizon:standing".to_string()));
535 }
536 atom["entities"] = Value::Array(entities);
537}
538
539fn prompt_fields(before: &Value) -> Value {
542 let mut after = before.clone();
543 stand_for_prompt(&mut after);
544 let mut fields = serde_json::Map::new();
545 if after.get("level") != before.get("level") {
546 if let Some(level) = after.get("level") {
547 fields.insert("level".to_string(), level.clone());
548 }
549 }
550 if after.get("entities") != before.get("entities") {
551 if let Some(entities) = after.get("entities") {
552 fields.insert("entities".to_string(), entities.clone());
553 }
554 }
555 Value::Object(fields)
556}
557
558pub fn satisfy_text(text: &str) {
561 let _g = gate();
562 let text = text.trim();
563 if text.is_empty() {
564 return;
565 }
566 let open: Vec<Record> = read_latest()
567 .into_values()
568 .filter(|rec| rec.status == "open" && rec.text.trim() == text)
569 .collect();
570 for mut rec in open {
571 rec.status = "satisfied".into();
572 if let Err(e) = append(&rec) {
576 eprintln!(
577 "ljos: a satisfied proposal was not recorded in {}: {e:#}",
578 proposals_path().display()
579 );
580 }
581 close_remote(&rec);
582 }
583}
584
585fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
586 let workspace = rec
587 .atom
588 .get("workspace")
589 .and_then(Value::as_str)
590 .unwrap_or("");
591 if workspace.is_empty() {
592 client.workspace()
593 } else {
594 workspace.to_string()
595 }
596}
597
598fn close_remote(rec: &Record) {
601 if !rec.remote {
602 return;
603 }
604 let Some(remote_id) = rec.remote_id.as_deref() else {
605 return;
606 };
607 let Ok(client) = crate::pack() else {
608 return;
609 };
610 let workspace = workspace_of(&client, rec);
611 let _ = accept_on_writer(&client, &workspace, remote_id);
612}
613
614fn accepted_line(rec: &Record, posted: &Value) -> String {
615 let origin = posted
616 .get("origin")
617 .and_then(Value::as_str)
618 .filter(|origin| !origin.is_empty())
619 .unwrap_or(ORIGIN_AGENT);
620 format!(
621 "accepted {} as {} origin {origin}{}\n{}",
622 rec.id,
623 rec.kind,
624 crate::revision_note(posted),
625 rec.grant.as_ref().map(Yes::line).unwrap_or_default()
626 )
627}
628
629fn relabel(
634 client: &PacksetClient,
635 workspace: &str,
636 accepted: Value,
637 origin: &str,
638) -> Result<Value> {
639 let stored = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
640 if stored == origin {
641 return Ok(accepted);
642 }
643 let Some(id) = accepted
644 .get("id")
645 .and_then(Value::as_str)
646 .filter(|id| !id.is_empty())
647 else {
648 bail!("accept stored the claim without an id, so its origin could not be put back to {origin}");
649 };
650 update_fields(client, workspace, id, serde_json::json!({"origin": origin})).with_context(|| {
651 format!("the claim {id} was stored as {stored}; putting the origin back to {origin} failed")
652 })
653}
654
655fn update_fields(
657 client: &PacksetClient,
658 workspace: &str,
659 id: &str,
660 fields: Value,
661) -> Result<Value> {
662 let body = serde_json::json!({"workspace": workspace, "id": id, "fields": fields});
663 let bytes = serde_json::to_vec(&body).unwrap_or_default();
664 match client.roundtrip(
665 "POST",
666 "/v1/atoms/update",
667 &[],
668 Some(&bytes),
669 true,
670 http_timeout(),
671 ) {
672 Ok(answer) if (200..300).contains(&answer.status) => {
673 serde_json::from_str(&answer.body).context("update: the pack's answer was not json")
674 }
675 Ok(answer) => bail!("update: {}: {}", answer.status, answer.body),
676 Err(e) => bail!("update: {e}"),
677 }
678}
679
680fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
681 let body = serde_json::json!({"workspace": workspace, "id": id});
682 let bytes = serde_json::to_vec(&body).unwrap_or_default();
683 match client.roundtrip(
684 "POST",
685 "/v1/proposals/accept",
686 &[],
687 Some(&bytes),
688 true,
689 http_timeout(),
690 ) {
691 Ok(answer) if (200..300).contains(&answer.status) => {
692 serde_json::from_str(&answer.body).context("accept: the pack's answer was not json")
693 }
694 Ok(answer) => {
695 bail!(
696 "accept: POST /v1/proposals/accept failed: {}: {}",
697 answer.status,
698 answer.body
699 )
700 }
701 Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
702 }
703}
704
705#[must_use]
709pub fn granted() -> Vec<(String, Yes)> {
710 let mut out: Vec<(String, Yes)> = read_latest()
711 .into_values()
712 .filter(|rec| rec.status == "accepted")
713 .filter_map(|rec| rec.grant.map(|yes| (rec.id, yes)))
714 .collect();
715 out.sort_by(|a, b| a.1.at.cmp(&b.1.at).then_with(|| a.0.cmp(&b.0)));
716 out
717}
718
719pub fn accept(id: &str) -> Result<String> {
722 accept_on(id, None)
723}
724
725#[must_use]
730pub fn accept_ids(ids: &[String], yes: Option<&crate::quoted_yes::Yes>) -> String {
731 let mut out = String::new();
732 for id in ids {
733 let line = match accept_on(id, yes) {
734 Ok(line) => line,
735 Err(err) => format!("refused {}: {err:#}\n", id.trim()),
736 };
737 if line.ends_with('\n') {
738 out.push_str(&line);
739 } else {
740 out.push_str(&line);
741 out.push('\n');
742 }
743 }
744 out
745}
746
747pub fn file_open(text: &str, kind: &str) -> Result<String> {
754 let text = text.trim();
755 if text.is_empty() {
756 bail!("proposals: empty text is not a proposal");
757 }
758 let kind = if kind.trim().is_empty() {
759 "lesson"
760 } else {
761 kind.trim()
762 };
763 let seat = proposing_seat();
764 let id = proposal_id(text, &seat);
765 let rec = Record {
766 id: id.clone(),
767 status: "open".into(),
768 remote: false,
769 remote_id: None,
770 stored: None,
771 text: text.to_string(),
772 kind: kind.to_string(),
773 origin: ORIGIN_AGENT.into(),
774 grant: None,
775 seat: seat.clone(),
776 at: crate::now_utc(),
777 reason: String::new(),
778 amendment: None,
779 atom: serde_json::json!({
780 "kind": kind,
781 "text": text,
782 "origin": ORIGIN_AGENT,
783 "level": LEVEL_L0,
784 "source": {"seat": &seat, "basis": "agent"},
785 }),
786 };
787 append(&rec)?;
788 Ok(format!("{id}\n"))
789}
790
791fn proposing_seat() -> String {
795 let seat = crate::seat_name();
796 if seat.is_empty() || crate::omitted_actor_name(&seat) {
797 String::new()
798 } else {
799 seat
800 }
801}
802
803fn proposal_id(text: &str, seat: &str) -> String {
804 use sha2::{Digest, Sha256};
805 let digest = Sha256::digest(format!("{seat}\n{text}").as_bytes());
806 digest.iter().map(|b| format!("{b:02x}")).take(16).collect()
807}
808
809pub fn amend_proposal(id: &str, text: &str) -> Result<String> {
816 let text = text.trim();
817 if text.is_empty() {
818 bail!("proposals: empty text is not an amendment");
819 }
820 let _g = gate();
821 let id = id.trim();
822 if id.is_empty() {
823 bail!("proposals: an id is required");
824 }
825 let rows = read_latest();
826 let known: Vec<String> = rows.keys().cloned().collect();
827 let id = match crate::resolve_short_id("proposals", id, &known) {
828 Ok(id) => id,
829 Err(err) => return Ok(format!("not found {id}: {err:#}\n")),
830 };
831 let id = id.as_str();
832 let Some(mut rec) = rows.get(id).cloned() else {
833 return Ok(format!("not found {id}\n"));
834 };
835 if !matches!(rec.status.as_str(), "open" | "proposed" | "refused") {
836 return Ok(format!("already in pack {id}\n"));
837 }
838 rec.amendment = Some(Amendment {
839 text: text.to_string(),
840 seat: crate::seat_name(),
841 at: crate::now_utc(),
842 });
843 rec.atom["text"] = Value::String(text.to_string());
844 if let Some(why) = crate::pack_copy::gate_refusal(text) {
845 rec.status = "refused".to_string();
846 rec.reason = why.clone();
847 rec.at = crate::now_utc();
848 append(&rec)?;
849 return Ok(amend_refusal(id, text, &why));
850 }
851 rec.status = "open".to_string();
852 rec.reason.clear();
853 rec.at = crate::now_utc();
854 append(&rec)?;
855 Ok(format!("ok {id}: passes (ready for ljos accept)\n"))
856}
857
858fn amend_refusal(id: &str, text: &str, why: &str) -> String {
860 let quote = trim_quote(&offending_sentence(text, why));
861 let (reason, value, limit) = measured(why);
862 match (value, limit) {
863 (Some(value), Some(limit)) if !quote.is_empty() => {
864 format!("refused {id}: {reason}; {value} vs limit {limit}: \"{quote}\"\n")
865 }
866 _ if !quote.is_empty() => format!("refused {id}: {why}: \"{quote}\"\n"),
867 _ => format!("refused {id}: {why}\n"),
868 }
869}
870
871fn measured(why: &str) -> (String, Option<String>, Option<String>) {
872 if let Some((words, limit)) = word_limit(why) {
873 return (
874 format!("sentence has {words} words"),
875 Some(words.to_string()),
876 Some(limit.to_string()),
877 );
878 }
879 if let Some(rest) = why.split("readability grade ").nth(1) {
880 let mut parts = rest.split_whitespace();
881 let grade = parts.next().unwrap_or("");
882 if parts.next() == Some("exceeds") {
883 let limit = parts
884 .next()
885 .unwrap_or("")
886 .trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
887 if !grade.is_empty() && !limit.is_empty() {
888 return (
889 format!("readability grade {grade} exceeds {limit}"),
890 Some(grade.to_string()),
891 Some(limit.to_string()),
892 );
893 }
894 }
895 }
896 if let (Some(n), Some(limit)) = (
897 number_before(why, " sentences"),
898 number_after(why, "at most "),
899 ) {
900 return (
901 format!("atom has {n} sentences"),
902 Some(n.to_string()),
903 Some(limit.to_string()),
904 );
905 }
906 if let Some(rest) = why.split("adverb ratio ").nth(1) {
907 let mut parts = rest.split_whitespace();
908 let ratio = parts.next().unwrap_or("");
909 if parts.next() == Some("exceeds") {
910 let limit = parts
911 .next()
912 .unwrap_or("")
913 .trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
914 if !ratio.is_empty() && !limit.is_empty() {
915 return (
916 format!("adverb ratio {ratio} exceeds {limit}"),
917 Some(ratio.to_string()),
918 Some(limit.to_string()),
919 );
920 }
921 }
922 }
923 (why.to_string(), None, None)
924}
925
926#[must_use]
931pub fn list_proposals(seat: Option<&str>, status: Option<&str>) -> String {
932 let held = live_atoms().unwrap_or_default();
933 let want_seat = seat.map(str::trim).filter(|s| !s.is_empty());
934 let want_status = status.map(str::trim).filter(|s| !s.is_empty());
935 let mut rows: Vec<Record> = read_latest().into_values().collect();
936 rows.retain(|rec| want_seat.is_none_or(|seat| seat_matches(rec, seat)));
937 rows.sort_by(|a, b| a.id.cmp(&b.id));
938 if rows.is_empty() {
939 return "no proposals\n".to_string();
940 }
941 if let Some(status) = want_status {
942 let mut out = String::new();
943 for rec in &rows {
944 if !status_selected(rec, status, &held) {
945 continue;
946 }
947 out.push_str(&proposal_line(rec, &held));
948 }
949 if out.is_empty() {
950 out.push_str("no proposals\n");
951 }
952 return out;
953 }
954 let mut fresh = String::new();
955 let mut refused = String::new();
956 let mut open_n = 0usize;
957 let mut new_n = 0usize;
958 let mut pack_n = 0usize;
959 for rec in &rows {
960 let open = matches!(rec.status.as_str(), "open" | "proposed");
961 if open {
962 open_n += 1;
963 if atom_in_pack(rec, &held) {
964 pack_n += 1;
965 } else {
966 new_n += 1;
967 fresh.push_str(&proposal_line(rec, &held));
968 }
969 } else if rec.status == "refused" {
970 refused.push_str(&proposal_line(rec, &held));
971 }
972 }
973 let mut out = fresh;
974 out.push_str(&refused);
975 out.push_str(&format!(
976 "{open_n} open: {new_n} new, {pack_n} already in pack (`--status in-pack` to list them)\n"
977 ));
978 out
979}
980
981pub fn settle_proposals(seat: Option<&str>) -> Result<String> {
989 let held = live_atoms().unwrap_or_default();
990 let want_seat = seat.map(str::trim).filter(|s| !s.is_empty());
991 let _g = gate();
992 let mut rows: Vec<Record> = read_latest().into_values().collect();
993 rows.retain(|rec| want_seat.is_none_or(|seat| seat_matches(rec, seat)));
994 rows.sort_by(|a, b| a.id.cmp(&b.id));
995 let mut lines = Vec::new();
996 for mut rec in rows {
997 if !matches!(rec.status.as_str(), "open" | "proposed") || !atom_in_pack(&rec, &held) {
998 continue;
999 }
1000 let id = rec.id.clone();
1001 note_outcome(&mut rec, "already-in-pack", "")?;
1002 lines.push(format!("already in pack {id}"));
1003 }
1004 let n = lines.len();
1005 lines.push(format!("settled {n}"));
1006 Ok(format!("{}\n", lines.join("\n")))
1007}
1008
1009fn live_atoms() -> Option<Vec<Value>> {
1012 let client = crate::pack().ok()?;
1013 crate::atoms_lean(&client, &client.workspace()).ok()
1014}
1015
1016fn proposal_kind(rec: &Record) -> String {
1017 if !rec.kind.trim().is_empty() {
1018 return rec.kind.trim().to_string();
1019 }
1020 rec.atom["kind"]
1021 .as_str()
1022 .unwrap_or("lesson")
1023 .trim()
1024 .to_string()
1025}
1026
1027fn proposal_text(rec: &Record) -> String {
1028 if let Some(text) = rec
1029 .amendment
1030 .as_ref()
1031 .map(|amendment| amendment.text.trim())
1032 .filter(|text| !text.is_empty())
1033 {
1034 return text.to_string();
1035 }
1036 let text = rec.text.trim();
1037 if !text.is_empty() {
1038 return text.to_string();
1039 }
1040 rec.atom["text"].as_str().unwrap_or("").trim().to_string()
1041}
1042
1043fn stamp_current(rec: &mut Record) {
1046 let text = proposal_text(rec);
1047 rec.atom["text"] = Value::String(text);
1048 if rec.amendment.is_some() {
1049 crate::add_entities(&mut rec.atom, ["provenance:amended".to_string()]);
1050 }
1051}
1052
1053fn amended_since_refusal(rec: &Record) -> bool {
1056 rec.amendment
1057 .as_ref()
1058 .is_some_and(|amendment| amendment.at > rec.at)
1059}
1060
1061fn atom_in_pack(rec: &Record, held: &[Value]) -> bool {
1064 let kind = proposal_kind(rec);
1065 let text = proposal_text(rec);
1066 if text.is_empty() || kind.is_empty() {
1067 return false;
1068 }
1069 let key = crate::memories::lesson_key(&kind, &text);
1070 held.iter().any(|atom| {
1071 let held_kind = atom["kind"].as_str().unwrap_or("").trim();
1072 if held_kind != kind {
1073 return false;
1074 }
1075 let same = |words: &str| crate::memories::lesson_key(held_kind, words) == key;
1076 same(atom["text"].as_str().unwrap_or(""))
1077 || atom["aliases"]
1078 .as_array()
1079 .is_some_and(|aliases| aliases.iter().filter_map(Value::as_str).any(same))
1080 })
1081}
1082
1083fn seat_matches(rec: &Record, seat: &str) -> bool {
1084 if seat == "unknown" || seat == "seat unknown" {
1085 rec.seat.trim().is_empty()
1086 } else {
1087 rec.seat == seat
1088 }
1089}
1090
1091fn wants_in_pack(status: &str) -> bool {
1092 status == "in-pack" || status == "already in pack"
1093}
1094
1095fn status_selected(rec: &Record, status: &str, held: &[Value]) -> bool {
1096 if wants_in_pack(status) {
1097 return rec.status == "already-in-pack"
1098 || (matches!(rec.status.as_str(), "open" | "proposed") && atom_in_pack(rec, held));
1099 }
1100 rec.status == status
1101}
1102
1103fn display_status(rec: &Record, held: &[Value]) -> String {
1104 if rec.status == "already-in-pack"
1105 || (matches!(rec.status.as_str(), "open" | "proposed") && atom_in_pack(rec, held))
1106 {
1107 "already in pack".to_string()
1108 } else {
1109 rec.status.clone()
1110 }
1111}
1112
1113fn proposal_seat(rec: &Record) -> String {
1114 let filed = rec.seat.trim();
1115 if !filed.is_empty() {
1116 return filed.to_string();
1117 }
1118 rec.atom["source"]["seat"]
1119 .as_str()
1120 .unwrap_or("")
1121 .trim()
1122 .to_string()
1123}
1124
1125fn proposal_line(rec: &Record, held: &[Value]) -> String {
1126 let named = proposal_seat(rec);
1127 let seat = if named.is_empty() {
1128 "seat unknown".to_string()
1129 } else {
1130 format!("seat {named}")
1131 };
1132 let text = if rec.status == "refused" && !rec.reason.is_empty() {
1133 format!(
1134 "{} amend it with ljos proposals amend",
1135 refusal_clause(&proposal_text(rec), &rec.reason)
1136 )
1137 } else {
1138 proposal_text(rec).replace('\n', " ")
1139 };
1140 format!(
1141 "{} {} {seat} {text}\n",
1142 rec.id,
1143 display_status(rec, held)
1144 )
1145}
1146
1147const QUOTE_CHARS: usize = 80;
1149
1150fn refusal_clause(text: &str, why: &str) -> String {
1153 let quote = trim_quote(&offending_sentence(text, why));
1154 if quote.is_empty() {
1155 return why.to_string();
1156 }
1157 if let Some((words, limit)) = word_limit(why) {
1158 format!("sentence has {words} words; limit {limit}: \"{quote}\"")
1159 } else {
1160 format!("{why}: \"{quote}\"")
1161 }
1162}
1163
1164fn word_limit(why: &str) -> Option<(usize, usize)> {
1165 let words = number_before(why, " words")?;
1166 let limit = number_after(why, "the limit is ")?;
1167 Some((words, limit))
1168}
1169
1170fn offending_sentence(text: &str, why: &str) -> String {
1171 let parts = packset_core::prose::sentences(text);
1172 if let Some(n) = number_after(why, "atom sentence ") {
1173 if let Some(sentence) = n.checked_sub(1).and_then(|i| parts.get(i)) {
1174 return sentence.trim().to_string();
1175 }
1176 }
1177 if parts.len() == 1 {
1178 return parts[0].trim().to_string();
1179 }
1180 text.trim().to_string()
1181}
1182
1183fn trim_quote(text: &str) -> String {
1184 let text = text.trim();
1185 if text.chars().count() <= QUOTE_CHARS {
1186 return text.to_string();
1187 }
1188 let keep = QUOTE_CHARS - 3;
1189 let mut out: String = text.chars().take(keep).collect();
1190 if let Some(space) = out.rfind(' ') {
1191 if space > keep / 2 {
1192 out.truncate(space);
1193 }
1194 }
1195 out.push_str("...");
1196 out
1197}
1198
1199fn number_before(text: &str, marker: &str) -> Option<usize> {
1200 let at = text.find(marker)?;
1201 let head = text[..at].trim_end();
1202 let digits: String = head
1203 .chars()
1204 .rev()
1205 .take_while(|c| c.is_ascii_digit())
1206 .collect();
1207 digits.chars().rev().collect::<String>().parse().ok()
1208}
1209
1210fn number_after(text: &str, marker: &str) -> Option<usize> {
1211 let rest = text.split(marker).nth(1)?;
1212 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1213 digits.parse().ok()
1214}
1215
1216pub fn accept_on(id: &str, yes: Option<&crate::quoted_yes::Yes>) -> Result<String> {
1232 let _g = gate();
1233 let id = id.trim();
1234 if id.is_empty() {
1235 bail!("accept: an id is required");
1236 }
1237 let rows = read_latest();
1238 let known: Vec<String> = rows.keys().cloned().collect();
1239 let id = match crate::resolve_short_id("accept", id, &known) {
1240 Ok(id) => id,
1241 Err(err) => return Ok(format!("not found {id}: {err:#}\n")),
1242 };
1243 let id = id.as_str();
1244 let Some(mut rec) = rows.get(id).cloned() else {
1245 return match accept_memory(id, None, yes) {
1246 Ok(line) => Ok(line),
1247 Err(err) if err.to_string().contains("no memory") => Ok(format!("not found {id}\n")),
1248 Err(err) => Err(err),
1249 };
1250 };
1251 rec.grant = yes.cloned();
1252 match rec.status.as_str() {
1253 "satisfied" => {
1254 close_remote(&rec);
1255 note_outcome(&mut rec, "already-in-pack", "")?;
1256 return Ok(format!("already in pack {id}\n"));
1257 }
1258 "accepted" | "already-in-pack" => return Ok(format!("already in pack {id}\n")),
1259 "stored" => {
1260 let stored = rec.stored.clone().unwrap_or_default();
1261 return accept_memory(&stored, Some(rec), yes);
1262 }
1263 "refused" if !amended_since_refusal(&rec) => {
1264 let when = if rec.at.is_empty() {
1265 "unknown"
1266 } else {
1267 rec.at.as_str()
1268 };
1269 return Ok(format!(
1270 "refused {id}: unchanged since last refusal at {when}; amend it with ljos proposals amend\n"
1271 ));
1272 }
1273 "open" | "proposed" | "refused" => {
1274 stamp_current(&mut rec);
1275 if matches!(rec.status.as_str(), "open" | "proposed") {
1276 if let Some(held) = live_atoms() {
1277 if atom_in_pack(&rec, &held) {
1278 note_outcome(&mut rec, "already-in-pack", "")?;
1279 return Ok(format!("already in pack {id}\n"));
1280 }
1281 }
1282 }
1283 }
1284 _ => return Ok(format!("not found {id}\n")),
1285 }
1286 let text = proposal_text(&rec);
1287 if let Some(why) = crate::pack_copy::gate_refusal(&text) {
1288 if rec.status != "refused" || rec.reason != why {
1289 rec.reason.clone_from(&why);
1290 note_outcome(&mut rec, "refused", &why)?;
1291 }
1292 return Ok(format!("refused {id}: {}\n", refusal_clause(&text, &why)));
1293 }
1294 let client = crate::pack()?;
1295 if rec.remote {
1296 let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
1297 let workspace = workspace_of(&client, &rec);
1298 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
1299 note_outcome(&mut rec, "accepted", "")?;
1300 return Ok(accepted_line(&rec, &posted));
1301 }
1302 stand_for_prompt(&mut rec.atom);
1306 match post_kept(&client, &rec.atom) {
1307 Ok(posted) => {
1308 rec.stored = posted["id"].as_str().map(str::to_string);
1309 note_outcome(&mut rec, "accepted", "")?;
1310 Ok(accepted_line(&rec, &posted))
1311 }
1312 Err(e) => {
1313 let err_text = e.to_string();
1314 let Some(remote_id) = held_proposal_id(&err_text) else {
1315 if !crate::note_can_wait(&e) {
1316 let why =
1317 crate::server_reason(&format!("{e:#}")).unwrap_or_else(|| format!("{e:#}"));
1318 note_outcome(&mut rec, "refused", &why)?;
1319 return Ok(format!(
1320 "refused {id}: {}\n",
1321 refusal_clause(&proposal_text(&rec), &why)
1322 ));
1323 }
1324 return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
1325 };
1326 let workspace = workspace_of(&client, &rec);
1329 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
1330 rec.remote = true;
1331 rec.remote_id = Some(remote_id);
1332 note_outcome(&mut rec, "accepted", "")?;
1333 Ok(accepted_line(&rec, &posted))
1334 }
1335 }
1336}
1337
1338fn note_outcome(rec: &mut Record, status: &str, reason: &str) -> Result<()> {
1341 if rec.status == status && rec.reason == reason && !rec.at.is_empty() {
1342 return Ok(());
1343 }
1344 rec.status = status.to_string();
1345 rec.reason = reason.to_string();
1346 rec.at = crate::now_utc();
1347 append(rec)
1348}
1349
1350fn accept_memory(id: &str, rec: Option<Record>, yes: Option<&Yes>) -> Result<String> {
1355 let client = crate::pack()?;
1356 let workspace = client.workspace();
1357 let atoms = crate::atoms_lean(&client, &workspace).context("accept: GET /v1/atoms failed")?;
1358 let all: Vec<crate::memories::Memory> = atoms
1359 .iter()
1360 .filter_map(crate::memories::Memory::of)
1361 .collect();
1362 let full = match crate::memories::resolve(id, &all) {
1363 Ok(full) => full,
1364 Err(err) if err.to_string().contains("no memory starts with") => {
1365 bail!("no memory {id}")
1366 }
1367 Err(err) => return Err(err).context(format!("accept: no open proposal or memory {id}")),
1368 };
1369 let atom = atoms
1370 .iter()
1371 .find(|a| a["id"].as_str() == Some(full.as_str()))
1372 .cloned()
1373 .unwrap_or_default();
1374 let fields = prompt_fields(&atom);
1375 let posted = if fields.as_object().is_some_and(|map| !map.is_empty()) {
1376 update_fields(&client, &workspace, &full, fields)?
1377 } else {
1378 atom.clone()
1379 };
1380 crate::memories::record(&full, "keep", None)?;
1381 let mut rec = rec.unwrap_or_else(|| Record {
1382 id: full.clone(),
1383 status: String::new(),
1384 remote: false,
1385 remote_id: None,
1386 stored: None,
1387 text: atom["text"].as_str().unwrap_or("").to_string(),
1388 kind: atom["kind"].as_str().unwrap_or("lesson").to_string(),
1389 origin: atom["origin"].as_str().unwrap_or("").to_string(),
1390 grant: None,
1391 seat: atom["source"]["seat"]
1392 .as_str()
1393 .unwrap_or("")
1394 .trim()
1395 .to_string(),
1396 at: String::new(),
1397 reason: String::new(),
1398 amendment: None,
1399 atom,
1400 });
1401 rec.stored = Some(full);
1402 rec.grant = yes.cloned();
1403 note_outcome(&mut rec, "accepted", "")?;
1404 Ok(accepted_line(&rec, &posted))
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use std::os::unix::fs::PermissionsExt;
1411
1412 fn record(id: String) -> Record {
1413 Record {
1414 id,
1415 status: "proposed".into(),
1416 remote: false,
1417 remote_id: None,
1418 stored: None,
1419 text: "use uv for python envs".into(),
1420 kind: "lesson".into(),
1421 origin: ORIGIN_AGENT.into(),
1422 grant: None,
1423 seat: String::new(),
1424 at: String::new(),
1425 reason: String::new(),
1426 amendment: None,
1427 atom: serde_json::json!({"text": "use uv for python envs"}),
1428 }
1429 }
1430
1431 #[test]
1433 fn a_proposal_names_the_seat_that_filed_it() {
1434 let _g = crate::test_env_lock();
1435 let dir = tempfile::tempdir().unwrap();
1436 unsafe {
1437 std::env::set_var("XDG_STATE_HOME", dir.path());
1438 std::env::set_var("LJOS_SEAT", "saddle");
1439 std::env::remove_var("PACKSET_URL");
1440 }
1441 let filed = file_open("Fire the kiln only after the shelf is cool.", "lesson").unwrap();
1442 assert!(!filed.trim().is_empty(), "{filed}");
1443 let listed = list_proposals(None, None);
1444 assert!(listed.contains("seat saddle"), "{listed}");
1445 assert!(!listed.contains("seat unknown"), "{listed}");
1446 let row = read_latest().into_values().next().unwrap();
1447 assert_eq!(row.seat, "saddle");
1448 assert_eq!(row.atom["source"]["seat"], "saddle");
1449 }
1450
1451 #[test]
1454 fn concurrent_proposal_rows_each_parse_and_stay_private() {
1455 let _g = crate::test_env_lock();
1456 let dir = tempfile::tempdir().unwrap();
1457 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1458 const THREADS: usize = 16;
1459 const EACH: usize = 8;
1460 let handles: Vec<_> = (0..THREADS)
1461 .map(|thread| {
1462 std::thread::spawn(move || {
1463 for n in 0..EACH {
1464 append(&record(format!("{thread}-{n}"))).unwrap();
1465 }
1466 })
1467 })
1468 .collect();
1469 for handle in handles {
1470 handle.join().expect("proposal writer");
1471 }
1472 let path = proposals_path();
1473 let text = std::fs::read_to_string(&path).unwrap();
1474 for line in text.lines() {
1475 serde_json::from_str::<Record>(line)
1476 .unwrap_or_else(|err| panic!("proposal row does not parse ({err}): {line}"));
1477 }
1478 assert_eq!(read_latest().len(), THREADS * EACH, "{text}");
1479 let mode = std::fs::symlink_metadata(&path)
1480 .unwrap()
1481 .permissions()
1482 .mode()
1483 & 0o777;
1484 assert_eq!(mode, 0o600, "proposals mode {mode:o}");
1485 }
1486
1487 #[test]
1490 fn a_torn_last_proposal_leaves_the_others() {
1491 let _g = crate::test_env_lock();
1492 let dir = tempfile::tempdir().unwrap();
1493 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1494 append(&record("a".into())).unwrap();
1495 let path = proposals_path();
1496 let mut bytes = std::fs::read(&path).unwrap();
1497 bytes.extend_from_slice(b"{\"id\":\"b\",\"text\":\"caf\xc3");
1498 std::fs::write(&path, bytes).unwrap();
1499 let ids: Vec<String> = read_latest().into_keys().collect();
1500 assert_eq!(ids, ["a"]);
1501 }
1502
1503 #[test]
1505 fn a_symlinked_proposals_file_is_refused() {
1506 let _g = crate::test_env_lock();
1507 let dir = tempfile::tempdir().unwrap();
1508 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1509 let path = proposals_path();
1510 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1511 let outside = dir.path().join("outside");
1512 std::fs::write(&outside, "kept\n").unwrap();
1513 std::os::unix::fs::symlink(&outside, &path).unwrap();
1514 assert!(append(&record("a".into())).is_err());
1515 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
1516 }
1517}