1#![allow(missing_docs)]
38
39use std::path::{Path, PathBuf};
40use std::sync::LazyLock;
41
42pub const STAGE_ONE_SYSTEM_PROMPT: &str = "\
53You are the memory-stage-one extractor.\n\
54\n\
55You MUST return strict JSON only — no markdown, no commentary.\n\
56\n\
57Extraction goals:\n\
58- You MUST distill reusable durable knowledge from rollout history.\n\
59- You MUST keep concrete technical signal (constraints, decisions, workflows, pitfalls, resolved failures).\n\
60- You NEVER include transient chatter or low-signal noise.\n\
61\n\
62Output contract (required keys):\n\
63{\n\
64 \"rollout_summary\": \"string\",\n\
65 \"rollout_slug\": \"string | null\",\n\
66 \"raw_memory\": \"string\"\n\
67}\n\
68\n\
69Rules:\n\
70- rollout_summary: compact synopsis of what future runs should remember.\n\
71- rollout_slug: short lowercase slug (letters/numbers/_), or null.\n\
72- raw_memory: detailed durable memory blocks with enough context to reuse.\n\
73- If no durable signal exists, you MUST return empty strings for rollout_summary/raw_memory and null rollout_slug.";
74
75pub const STAGE_ONE_USER_TEMPLATE: &str = "\
77thread_id: {{thread_id}}\n\
78\n\
79Persistable response items (JSON):\n\
80{{response_items_json}}\n\
81\n\
82You MUST extract durable memory now.";
83
84pub const CONSOLIDATION_SYSTEM_PROMPT: &str = "\
86You are the memory-stage-two consolidator.\n\
87\n\
88Follow the user-provided consolidation task exactly.\n\
89Return strict JSON only — no markdown, no commentary.";
90
91pub const CONSOLIDATION_USER_TEMPLATE: &str = "\
95Memory consolidation agent.\n\
96Memory root: memory://root\n\
97Input corpus (raw memories):\n\
98{{raw_memories}}\n\
99Input corpus (rollout summaries):\n\
100{{rollout_summaries}}\n\
101Produce strict JSON only with this schema — you NEVER include any other output:\n\
102{\n\
103 \"memory_md\": \"string\",\n\
104 \"memory_summary\": \"string\",\n\
105 \"skills\": [\n\
106 {\n\
107 \"name\": \"string\",\n\
108 \"content\": \"string\",\n\
109 \"scripts\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
110 \"templates\": [{ \"path\": \"string\", \"content\": \"string\" }],\n\
111 \"examples\": [{ \"path\": \"string\", \"content\": \"string\" }]\n\
112 }\n\
113 ]\n\
114}\n\
115\n\
116Requirements:\n\
117- memory_md: long-term memory document.\n\
118- memory_summary: prompt-time memory guidance.\n\
119- skills: reusable playbooks. Empty array allowed.\n\
120- skill.name maps to skills/<name>/.\n\
121- skill.content maps to skills/<name>/SKILL.md.\n\
122- scripts/templates/examples: optional. Each entry MUST write to skills/<name>/<bucket>/<path>.\n\
123- Only include files worth keeping long-term. Omit stale assets so they are pruned.\n\
124- Preserve useful prior themes. Remove stale or contradictory guidance.\n\
125- Treat memory as advisory: current repository state wins.";
126
127pub const READ_PATH_PROMPT: &str = "\
130# Memory Guidance\n\
131Memory root: memory://root\n\
132Operational rules:\n\
1331) Read `memory://root/memory_summary.md` first.\n\
1342) If needed, inspect `memory://root/MEMORY.md` and `memory://root/skills/<name>/SKILL.md`.\n\
1353) Trust memory for heuristics and process context. Trust current repo files, runtime output, and user instruction for factual state and final decisions.\n\
1364) When memory changes your plan, cite the artifact path (e.g. `memory://root/skills/<name>/SKILL.md`) and pair it with current-repo evidence.\n\
1375) If memory disagrees with repo state or user instruction, treat memory as stale: proceed with corrected behavior, then update/regenerate memory artifacts.\n\
1386) Escalate confidence only after repository verification. Memory alone is NEVER sufficient proof.\n\
139{{memory_summary_block}}{{learned_block}}";
140
141pub const READ_PATH_SUMMARY_TEMPLATE: &str = "\
143Memory summary:\n\
144{{memory_summary}}\n\
145";
146
147pub const READ_PATH_LEARNED_TEMPLATE: &str = "\
149Learned lessons (captured via the `learn` tool; durable but may be stale — verify against the repo before relying on them):\n\
150{{learned}}\n\
151";
152
153pub const DEFAULT_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
157pub const DEFAULT_MIN_ROLLOUT_IDLE_HOURS: i64 = 12;
159pub const DEFAULT_MAX_ROLLOUTS_PER_STARTUP: usize = 64;
161pub const DEFAULT_SUMMARY_INJECTION_TOKEN_LIMIT: usize = 5_000;
164pub const DEFAULT_GLOBAL_LEASE_SECONDS: i64 = 60;
167pub const DEFAULT_GLOBAL_HEARTBEAT_SECONDS: i64 = 30;
169pub const DEFAULT_MODEL_TOKEN_BUDGET: usize = 8_000;
171
172struct SecretPattern {
182 name: &'static str,
184 prefix: &'static str,
186}
187
188static SECRET_PATTERNS: LazyLock<Vec<SecretPattern>> = LazyLock::new(|| {
189 vec![
190 SecretPattern {
191 name: "openai",
192 prefix: "sk-",
193 },
194 SecretPattern {
195 name: "anthropic",
196 prefix: "sk-ant-",
197 },
198 SecretPattern {
199 name: "github_pat",
200 prefix: "ghp_",
201 },
202 SecretPattern {
203 name: "github_oauth",
204 prefix: "gho_",
205 },
206 SecretPattern {
207 name: "google_api",
208 prefix: "AIza",
209 },
210 SecretPattern {
211 name: "slack",
212 prefix: "xoxb-",
213 },
214 ]
215});
216
217pub fn redact_line(line: &str) -> String {
220 let mut out = String::with_capacity(line.len());
221 let mut rest = line;
222 while !rest.is_empty() {
223 let mut matched = false;
224 for pat in SECRET_PATTERNS.iter() {
225 if let Some(idx) = rest.find(pat.prefix) {
226 let after = &rest[idx + pat.prefix.len()..];
228 let tail = after
229 .find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
230 .unwrap_or(after.len());
231 let secret_len = pat.prefix.len() + tail;
232 out.push_str(&rest[..idx]);
233 out.push_str(&format!("[REDACTED:{}]", pat.name));
234 rest = &rest[idx + secret_len..];
235 matched = true;
236 break;
237 }
238 }
239 if !matched {
240 out.push_str(rest);
241 break;
242 }
243 }
244 out
245}
246
247pub fn redact_secrets(text: &str) -> String {
249 let mut out = String::with_capacity(text.len());
250 for line in text.split_inclusive('\n') {
251 out.push_str(&redact_line(line));
252 }
253 out
254}
255
256#[allow(clippy::manual_pattern_char_comparison)]
262pub fn memory_root(home: &Path, cwd: &str) -> PathBuf {
263 let stripped = cwd.trim_start_matches(|c: char| c == '/' || c == '\\');
264 let encoded = format!("--{}--", stripped.replace(['/', '\\', ':'], "-"));
265 home.join("memory").join(encoded)
266}
267
268pub const MEMORY_MD: &str = "MEMORY.md";
270pub const MEMORY_SUMMARY_MD: &str = "memory_summary.md";
271pub const LEARNED_MD: &str = "learned.md";
272
273pub fn sanitize_skill_name(name: &str) -> String {
275 name.chars()
276 .map(|c| {
277 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
278 c.to_ascii_lowercase()
279 } else {
280 '-'
281 }
282 })
283 .collect()
284}
285
286pub fn sanitize_skill_relative_path(raw: &str) -> Option<String> {
289 let mut parts: Vec<&str> = raw
290 .split('/')
291 .filter(|s| !s.is_empty() && *s != "." && *s != "..")
292 .collect();
293 if parts.is_empty() {
294 return None;
295 }
296 parts.sort();
297 Some(parts.join("/"))
298}
299
300pub fn render_read_path(summary: Option<&str>, learned: Option<&str>) -> String {
308 let summary_block = summary
309 .map(|s| READ_PATH_SUMMARY_TEMPLATE.replace("{{memory_summary}}", s))
310 .unwrap_or_default();
311 let learned_block = learned
312 .map(|s| READ_PATH_LEARNED_TEMPLATE.replace("{{learned}}", s))
313 .unwrap_or_default();
314 READ_PATH_PROMPT
315 .replace("{{memory_summary_block}}", &summary_block)
316 .replace("{{learned_block}}", &learned_block)
317}
318
319#[derive(Debug, Clone, serde::Deserialize)]
329pub struct ConsolidationSkill {
330 pub name: String,
331 pub content: String,
332 #[serde(default)]
333 pub scripts: Vec<ConsolidationFile>,
334 #[serde(default)]
335 pub templates: Vec<ConsolidationFile>,
336 #[serde(default)]
337 pub examples: Vec<ConsolidationFile>,
338}
339
340#[derive(Debug, Clone, serde::Deserialize)]
342pub struct ConsolidationFile {
343 pub path: String,
344 pub content: String,
345}
346
347#[derive(Debug, Clone, serde::Deserialize)]
349pub struct ConsolidationOutput {
350 #[serde(default)]
351 pub memory_md: String,
352 #[serde(default)]
353 pub memory_summary: String,
354 #[serde(default)]
355 pub skills: Vec<ConsolidationSkill>,
356}
357
358pub fn write_consolidation_artifacts(
365 memory_root: &Path,
366 out: &ConsolidationOutput,
367) -> std::io::Result<()> {
368 std::fs::create_dir_all(memory_root)?;
369
370 let memory_md = redact_secrets(&out.memory_md);
371 let summary = redact_secrets(&out.memory_summary);
372
373 let memory_path = memory_root.join(MEMORY_MD);
374 let summary_path = memory_root.join(MEMORY_SUMMARY_MD);
375 std::fs::write(&memory_path, memory_md.as_bytes())?;
376 std::fs::write(&summary_path, summary.as_bytes())?;
377
378 let skills_root = memory_root.join("skills");
379 std::fs::create_dir_all(&skills_root)?;
380
381 let mut retained: std::collections::HashSet<String> = std::collections::HashSet::new();
382 for skill in &out.skills {
383 let dir_name = sanitize_skill_name(&skill.name);
384 if dir_name.is_empty() || dir_name == "-" {
385 continue;
386 }
387 let skill_dir = skills_root.join(&dir_name);
388 std::fs::create_dir_all(&skill_dir)?;
389 retained.insert(dir_name.clone());
390
391 std::fs::write(
392 skill_dir.join("SKILL.md"),
393 redact_secrets(&skill.content).as_bytes(),
394 )?;
395 for (bucket, files) in [
396 ("scripts", &skill.scripts),
397 ("templates", &skill.templates),
398 ("examples", &skill.examples),
399 ] {
400 if files.is_empty() {
401 continue;
402 }
403 let bucket_root = skill_dir.join(bucket);
404 std::fs::create_dir_all(&bucket_root)?;
405 for file in files.iter() {
406 let Some(rel) = sanitize_skill_relative_path(&file.path) else {
407 continue;
408 };
409 let target = bucket_root.join(&rel);
410 if let Some(parent) = target.parent() {
411 std::fs::create_dir_all(parent)?;
412 }
413 std::fs::write(&target, redact_secrets(&file.content).as_bytes())?;
414 }
415 }
416 }
417
418 if let Ok(entries) = std::fs::read_dir(&skills_root) {
419 for entry in entries.flatten() {
420 let path = entry.path();
421 if path.is_dir()
422 && let Some(name) = path.file_name().and_then(|s| s.to_str())
423 && !retained.contains(name)
424 {
425 let _ = std::fs::remove_dir_all(&path);
426 }
427 }
428 }
429 Ok(())
430}
431
432pub fn load_consolidated_artifacts(
435 memory_root: &Path,
436) -> std::io::Result<(Option<String>, Option<String>)> {
437 let memory_md = std::fs::read(memory_root.join(MEMORY_MD))
438 .ok()
439 .and_then(|b| String::from_utf8(b).ok());
440 let memory_summary = std::fs::read(memory_root.join(MEMORY_SUMMARY_MD))
441 .ok()
442 .and_then(|b| String::from_utf8(b).ok());
443 Ok((memory_md, memory_summary))
444}
445
446pub fn parse_consolidation_output(text: &str) -> Option<ConsolidationOutput> {
450 let trimmed = text
451 .trim()
452 .trim_start_matches("```json")
453 .trim_start_matches("```")
454 .trim_end_matches("```")
455 .trim();
456 serde_json::from_str(trimmed).ok()
457}
458
459pub fn parse_stage1_output(text: &str) -> Option<Stage1OutputShape> {
461 let trimmed = text
462 .trim()
463 .trim_start_matches("```json")
464 .trim_start_matches("```")
465 .trim_end_matches("```")
466 .trim();
467 serde_json::from_str(trimmed).ok()
468}
469
470#[derive(Debug, Clone, serde::Deserialize)]
472pub struct Stage1OutputShape {
473 #[serde(default)]
474 pub rollout_summary: String,
475 #[serde(default)]
476 pub rollout_slug: Option<String>,
477 #[serde(default)]
478 pub raw_memory: String,
479}