flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/work/src/memory.rs

608 lines25,201 bytesCodeBlame
1//! Memory: what agents and people have learned that the next agent should
2//! know, at two levels. A project's memory is about its codebase; the
3//! workspace's is true across its projects ("we use pnpm everywhere",
4//! "staging lives at …").
5//!
6//! It is members-only, since it can hold internal knowledge, and it never
7//! holds a secret: text that looks like a key or a token is refused. Every
8//! g1t agent run is given it (`memory_context`): pinned first, then what
9//! was used most recently, within a size budget.
10
11use g1t_contracts::agents::*;
12use g1t_contracts::repos::{Repo, RepoPath};
13use g1t_contracts::time::rfc3339;
14use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id};
15use g1t_kit::now_ms;
16use serde::Deserialize;
17use worker::Result;
18use worker::wasm_bindgen::JsValue;
19
20use crate::Work;
21use crate::runs::member_of;
22
23/// A workspace renamed: its runs and memories move to the slug it has now.
24/// `?1` is the current slug, `?2` a stale one (see `g1t_kit::rename`).
25pub(crate) const RENAMED: &[&str] = &[
26 "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2",
27 "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
28 "UPDATE memories SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
29 "UPDATE memories SET workspace = ?1 WHERE workspace = ?2",
30 "UPDATE memories SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
31 "UPDATE memories SET source_repo = ?1 || substr(source_repo, length(?2) + 1) WHERE substr(source_repo, 1, length(?2) + 1) = ?2 || '/'",
32];
33
34/// The most memories one project, or one workspace, keeps.
35const MAX_PER_SCOPE: u32 = 500;
36/// What an agent is given by default, in characters.
37const DEFAULT_BUDGET: u32 = 6000;
38const MAX_BUDGET: u32 = 20_000;
39const DEFAULT_RECALL: u32 = 20;
40
41#[derive(Deserialize)]
42pub(crate) struct MemoryRow {
43 id: String,
44 pub(crate) scope: String,
45 pub(crate) scope_key: String,
46 workspace: String,
47 repo: Option<String>,
48 text: String,
49 kind: String,
50 source_kind: String,
51 source_run: Option<String>,
52 source_repo: Option<String>,
53 source_number: Option<u32>,
54 created_by: String,
55 pinned: u32,
56 created_at: String,
57 updated_at: String,
58 last_used_at: Option<String>,
59 // What capture adds (capture.rs, migration 0019).
60 #[serde(default)]
61 status: Option<String>,
62 #[serde(default)]
63 confidence: Option<f64>,
64 #[serde(default)]
65 source_ref: Option<String>,
66 #[serde(default)]
67 evidence: Option<String>,
68 #[serde(default)]
69 sources: Option<String>,
70}
71
72fn path(text: &str) -> Option<RepoPath> {
73 let (namespace, name) = text.split_once('/')?;
74 Some(RepoPath {
75 namespace: namespace.to_owned(),
76 name: name.to_owned(),
77 })
78}
79
80impl From<MemoryRow> for Memory {
81 fn from(row: MemoryRow) -> Self {
82 Memory {
83 id: row.id,
84 scope: if row.scope == "workspace" {
85 MemoryScope::Workspace
86 } else {
87 MemoryScope::Project
88 },
89 workspace: row.workspace,
90 repo: row.repo.as_deref().and_then(path),
91 text: row.text,
92 kind: MemoryKind::parse(&row.kind).unwrap_or_default(),
93 source: MemorySource {
94 kind: row.source_kind,
95 run_id: row.source_run,
96 repo: row.source_repo.as_deref().and_then(path),
97 number: row.source_number,
98 reference: row.source_ref,
99 evidence: row.evidence,
100 },
101 created_by: row.created_by,
102 pinned: row.pinned != 0,
103 created_at: row.created_at,
104 updated_at: row.updated_at,
105 last_used_at: row.last_used_at,
106 status: row.status.as_deref().and_then(MemoryStatus::parse).unwrap_or_default(),
107 confidence: row.confidence,
108 seen: row
109 .sources
110 .as_deref()
111 .and_then(|sources| serde_json::from_str::<Vec<String>>(sources).ok())
112 .map_or(1, |sources| sources.len().max(1) as u32),
113 }
114 }
115}
116
117/// The text tidied, or why it cannot be kept.
118fn valid_text(text: &str) -> std::result::Result<String, String> {
119 let text = text.trim();
120 if text.is_empty() {
121 return Err("Write what should be remembered.".into());
122 }
123 if text.chars().count() > MAX_MEMORY_CHARS {
124 return Err(format!(
125 "Keep a memory to {MAX_MEMORY_CHARS} characters: one fact, convention, decision or gotcha each."
126 ));
127 }
128 if let Some(what) = secret_in(text) {
129 return Err(format!(
130 "That looks like {what}. Memory is read by every agent in the workspace, so it never holds secrets. Say where the secret lives instead, such as \"the deploy key is the DEPLOY_KEY secret\"."
131 ));
132 }
133 Ok(text.to_owned())
134}
135
136fn denied<T>() -> Outcome<T> {
137 Outcome::fail(
138 FailureCode::Forbidden,
139 "Memory is for members of the workspace and its agents.",
140 )
141}
142
143/// Whether `actor` may change `workspace`'s memory.
144fn may_write(actor: &User, workspace: &str) -> bool {
145 actor.is_member(workspace) && (actor.verified || actor.kind != PrincipalKind::User)
146}
147
148/// The order memories are given and listed in: pinned, then most recently
149/// used or changed.
150const ORDER: &str = "pinned DESC, COALESCE(last_used_at, updated_at) DESC, created_at DESC";
151
152/// What an agent is told about memory, ahead of the memories themselves.
153const PREAMBLE: &str = "What people and agents have learned here before you, kept as memory. Treat it as notes from colleagues: usually right, sometimes out of date. Where it disagrees with the code, the code wins; say so in your summary.";
154
155/// How an agent is told to add to memory.
156const HOW_TO_REMEMBER: &str = "When you learn something the next agent here would need (how to build or test, a convention, a decision and why, a trap), save it with the remember tool: scope project for this codebase, workspace for what holds across the workspace's projects. One short fact each. Never a secret, a key or a token. recall searches what is kept.";
157
158/// The context an agent gets: as many memories as fit in `budget`, each
159/// level labelled, and the ids of those given.
160fn compose(workspace: &[Memory], project: &[Memory], repo: &RepoPath, budget: usize) -> (Option<String>, Vec<String>) {
161 let line = |memory: &Memory| {
162 format!(
163 "- [{}{}] {}",
164 memory.kind.as_str(),
165 if memory.pinned { ", pinned" } else { "" },
166 memory.text.replace('\n', " ")
167 )
168 };
169 let mut used = PREAMBLE.len() + HOW_TO_REMEMBER.len() + 200;
170 let mut given = Vec::new();
171 let mut sections = Vec::new();
172 // Pinned memories of either level go in before anything else does.
173 let mut take = |memories: &[Memory], pinned: bool, out: &mut Vec<String>| {
174 for memory in memories.iter().filter(|memory| memory.pinned == pinned) {
175 let text = line(memory);
176 if used + text.len() + 1 > budget {
177 continue;
178 }
179 used += text.len() + 1;
180 given.push(memory.id.clone());
181 out.push(text);
182 }
183 };
184 let (mut ws, mut own) = (Vec::new(), Vec::new());
185 take(workspace, true, &mut ws);
186 take(project, true, &mut own);
187 take(project, false, &mut own);
188 take(workspace, false, &mut ws);
189 if ws.is_empty() && own.is_empty() {
190 return (None, given);
191 }
192 sections.push(PREAMBLE.to_owned());
193 if !ws.is_empty() {
194 sections.push(format!(
195 "Workspace memory (true across the {} workspace's projects):\n{}",
196 repo.namespace,
197 ws.join("\n")
198 ));
199 }
200 if !own.is_empty() {
201 sections.push(format!(
202 "Project memory ({}/{}):\n{}",
203 repo.namespace,
204 repo.name,
205 own.join("\n")
206 ));
207 }
208 sections.push(HOW_TO_REMEMBER.to_owned());
209 (Some(sections.join("\n\n")), given)
210}
211
212impl Work {
213 async fn memories_of(&self, scope: MemoryScope, key: &str, limit: u32) -> Result<Vec<Memory>> {
214 Ok(self
215 .db
216 .prepare(format!(
217 // Only what is kept: candidates wait for review (capture.rs).
218 "SELECT * FROM memories WHERE scope = ? AND scope_key = ? AND status = 'kept' ORDER BY {ORDER} LIMIT ?"
219 ))
220 .bind(&[scope.as_str().into(), key.into(), limit.into()])?
221 .all()
222 .await?
223 .results::<MemoryRow>()?
224 .into_iter()
225 .map(Memory::from)
226 .collect())
227 }
228
229 async fn memory_row(&self, workspace: &str, id: &str) -> Result<Option<Memory>> {
230 Ok(self
231 .db
232 .prepare("SELECT * FROM memories WHERE id = ? AND workspace = ?")
233 .bind(&[id.into(), workspace.into()])?
234 .first::<MemoryRow>(None)
235 .await?
236 .map(Memory::from))
237 }
238
239 /// The repository a project's memory is about; none for the workspace's.
240 async fn memory_repo_id(&self, id: &str) -> Result<Option<String>> {
241 Ok(self
242 .db
243 .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'")
244 .bind(&[id.into()])?
245 .first::<String>(Some("value"))
246 .await?)
247 }
248
249 async fn mark_used(&self, ids: &[String]) -> Result<()> {
250 if ids.is_empty() {
251 return Ok(());
252 }
253 self.db
254 .prepare("UPDATE memories SET last_used_at = ? WHERE id IN (SELECT value FROM json_each(?))")
255 .bind(&[rfc3339(now_ms()).into(), serde_json::to_string(ids)?.into()])?
256 .run()
257 .await?;
258 Ok(())
259 }
260
261 /// The project's repository, which must be in `workspace`.
262 async fn project_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
263 Ok(match self.repo(path, viewer).await? {
264 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
265 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
266 Outcome::Fail(failure) => Outcome::Fail(failure),
267 })
268 }
269
270 pub(crate) async fn list_memories(&self, a: ListMemoriesArgs) -> Result<Outcome<Memories>> {
271 let workspace = a.workspace.to_lowercase();
272 if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)) {
273 return Ok(denied());
274 }
275 let project = match &a.repo {
276 Some(path) => match self.project_repo(path, &a.viewer, &workspace).await? {
277 Outcome::Ok(repo) => self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?,
278 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
279 },
280 None => Vec::new(),
281 };
282 Ok(Outcome::Ok(Memories {
283 project,
284 workspace: self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?,
285 }))
286 }
287
288 pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> {
289 let workspace = a.workspace.to_lowercase();
290 if !may_write(&a.actor, &workspace) {
291 return Ok(denied());
292 }
293 let text = match valid_text(&a.text) {
294 Ok(text) => text,
295 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
296 };
297 let viewer = Some(a.actor.clone());
298 let repo = match &a.repo {
299 Some(path) => match self.project_repo(path, &viewer, &workspace).await? {
300 Outcome::Ok(repo) => Some(repo),
301 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
302 },
303 None => None,
304 };
305 let key = match (a.scope, &repo) {
306 (MemoryScope::Workspace, _) => workspace.clone(),
307 (MemoryScope::Project, Some(repo)) => repo.id.clone(),
308 (MemoryScope::Project, None) => {
309 return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about."));
310 }
311 };
312 // The same thing remembered twice is one memory, freshened; written
313 // by hand, a candidate or a dismissed one with the same words is
314 // kept.
315 let now = rfc3339(now_ms());
316 let print = g1t_contracts::capture::fingerprint(&text);
317 let same = self
318 .db
319 .prepare(
320 "UPDATE memories SET updated_at = ?, status = 'kept'
321 WHERE scope = ? AND scope_key = ? AND (text = ? OR fingerprint = ?) RETURNING id AS value",
322 )
323 .bind(&[now.as_str().into(), a.scope.as_str().into(), key.as_str().into(), text.as_str().into(), print.as_str().into()])?
324 .first::<String>(Some("value"))
325 .await?;
326 if let Some(id) = same {
327 let repo_id = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
328 self.memory_changed(&id, &workspace, "kept", repo_id.as_deref()).await;
329 return Ok(match self.memory_row(&workspace, &id).await? {
330 Some(memory) => Outcome::Ok(memory),
331 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
332 });
333 }
334 let count = self
335 .db
336 .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'")
337 .bind(&[a.scope.as_str().into(), key.as_str().into()])?
338 .first::<u32>(Some("value"))
339 .await?
340 .unwrap_or_default();
341 if count >= MAX_PER_SCOPE {
342 return Ok(Outcome::fail(
343 FailureCode::Conflict,
344 format!("This {} already keeps {MAX_PER_SCOPE} memories. Remove some that no longer hold first.", a.scope.as_str()),
345 ));
346 }
347 // Where it came from: a person, or an agent, and the run it was in.
348 let from = match (&repo, a.from_number) {
349 (Some(repo), Some(number)) => Some((repo, number)),
350 _ => None,
351 };
352 let run = match (a.actor.kind, from) {
353 (PrincipalKind::Agent, Some((repo, number))) => self.active_run_on(&repo.id, number).await?,
354 _ => None,
355 };
356 let source_kind = match (a.actor.kind, &run) {
357 (PrincipalKind::User, _) => "person",
358 (_, Some(_)) => "run",
359 _ => "agent",
360 };
361 let id = new_id("mem", now_ms());
362 let repo_text = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
363 // Its source, as capture counts sources (capture.rs).
364 let reference = match &run {
365 Some(run) => format!("run:{run}"),
366 None => format!("{source_kind}:{}", a.actor.username),
367 };
368 let changed_repo = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
369 self.db
370 .prepare(
371 "INSERT INTO memories
372 (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run,
373 source_repo, source_number, created_by, pinned, created_at, updated_at,
374 status, fingerprint, sources, source_ref)
375 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
376 )
377 .bind(&[
378 id.as_str().into(),
379 a.scope.as_str().into(),
380 key.clone().into(),
381 workspace.as_str().into(),
382 // A workspace's memory belongs to no one project, though it
383 // remembers which it was learned in.
384 if a.scope == MemoryScope::Project { repo_text.clone().map_or(JsValue::NULL, JsValue::from) } else { JsValue::NULL },
385 text.into(),
386 a.kind.as_str().into(),
387 source_kind.into(),
388 run.map_or(JsValue::NULL, JsValue::from),
389 repo_text.map_or(JsValue::NULL, JsValue::from),
390 from.map_or(JsValue::NULL, |(_, number)| number.into()),
391 a.actor.username.as_str().into(),
392 u32::from(a.pinned).into(),
393 now.as_str().into(),
394 now.as_str().into(),
395 print.as_str().into(),
396 serde_json::to_string(&[&reference])?.into(),
397 reference.as_str().into(),
398 ])?
399 .run()
400 .await?;
401 self.memory_changed(&id, &workspace, "kept", changed_repo.as_deref()).await;
402 Ok(match self.memory_row(&workspace, &id).await? {
403 Some(memory) => Outcome::Ok(memory),
404 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
405 })
406 }
407
408 pub(crate) async fn update_memory(&self, a: UpdateMemoryArgs) -> Result<Outcome<Memory>> {
409 let workspace = a.workspace.to_lowercase();
410 if !may_write(&a.actor, &workspace) || a.actor.kind == PrincipalKind::Agent {
411 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members of the workspace can change its memory."));
412 }
413 let Some(before) = self.memory_row(&workspace, &a.id).await? else {
414 return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found."));
415 };
416 let text = match a.text.as_deref().map(valid_text) {
417 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
418 Some(Ok(text)) => Some(text),
419 None => None,
420 };
421 let print = text.as_deref().map(g1t_contracts::capture::fingerprint);
422 self.db
423 .prepare(
424 "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind),
425 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
426 WHERE id = ? AND workspace = ?",
427 )
428 .bind(&[
429 text.map_or(JsValue::NULL, JsValue::from),
430 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
431 a.pinned.map_or(JsValue::NULL, |pinned| u32::from(pinned).into()),
432 print.map_or(JsValue::NULL, JsValue::from),
433 rfc3339(now_ms()).into(),
434 a.id.as_str().into(),
435 workspace.as_str().into(),
436 ])?
437 .run()
438 .await?;
439 let repo_id = self.memory_repo_id(&a.id).await?;
440 self.memory_changed(&a.id, &workspace, before.status.as_str(), repo_id.as_deref()).await;
441 Ok(match self.memory_row(&workspace, &a.id).await? {
442 Some(memory) => Outcome::Ok(memory),
443 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
444 })
445 }
446
447 pub(crate) async fn delete_memory(&self, a: DeleteMemoryArgs) -> Result<Outcome<bool>> {
448 let workspace = a.workspace.to_lowercase();
449 if !may_write(&a.actor, &workspace) || a.actor.kind == PrincipalKind::Agent {
450 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members of the workspace can change its memory."));
451 }
452 let repo_id = self.memory_repo_id(&a.id).await?;
453 let gone = self
454 .db
455 .prepare("DELETE FROM memories WHERE id = ? AND workspace = ? RETURNING id AS value")
456 .bind(&[a.id.as_str().into(), workspace.as_str().into()])?
457 .first::<String>(Some("value"))
458 .await?;
459 Ok(match gone {
460 Some(_) => {
461 self.memory_changed(&a.id, &workspace, "deleted", repo_id.as_deref()).await;
462 Outcome::Ok(true)
463 }
464 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
465 })
466 }
467
468 pub(crate) async fn recall(&self, a: RecallArgs) -> Result<Outcome<Memories>> {
469 let repo = match self.repo(&a.repo, &a.viewer).await? {
470 Outcome::Ok(repo) => repo,
471 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
472 };
473 let workspace = repo.namespace.to_lowercase();
474 if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)) {
475 return Ok(denied());
476 }
477 let words: Vec<String> = a
478 .query
479 .as_deref()
480 .unwrap_or_default()
481 .split_whitespace()
482 .map(str::to_lowercase)
483 .collect();
484 let limit = a.limit.unwrap_or(DEFAULT_RECALL).clamp(1, 100) as usize;
485 let matching = |memories: Vec<Memory>| -> Vec<Memory> {
486 memories
487 .into_iter()
488 .filter(|memory| {
489 let text = memory.text.to_lowercase();
490 words.iter().all(|word| text.contains(word.as_str()))
491 })
492 .take(limit)
493 .collect()
494 };
495 let found = Memories {
496 project: matching(self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?),
497 workspace: matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?),
498 };
499 let ids: Vec<String> = found
500 .project
501 .iter()
502 .chain(&found.workspace)
503 .map(|memory| memory.id.clone())
504 .collect();
505 self.mark_used(&ids).await?;
506 Ok(Outcome::Ok(found))
507 }
508
509 pub(crate) async fn memory_context(&self, a: MemoryContextArgs) -> Result<MemoryContext> {
510 let service = User {
511 id: "g1t_runner".into(),
512 username: "g1t".into(),
513 ..User::default()
514 };
515 let Outcome::Ok(repo) = self.repo(&a.repo, &member_of(&service, &a.repo.namespace)).await? else {
516 return Ok(MemoryContext::default());
517 };
518 let workspace = self
519 .memories_of(MemoryScope::Workspace, &repo.namespace.to_lowercase(), MAX_PER_SCOPE)
520 .await?;
521 let project = self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?;
522 let budget = a.budget.unwrap_or(DEFAULT_BUDGET).clamp(500, MAX_BUDGET) as usize;
523 let path = RepoPath {
524 namespace: repo.namespace.clone(),
525 name: repo.name.clone(),
526 };
527 let (text, given) = compose(&workspace, &project, &path, budget);
528 self.mark_used(&given).await?;
529 Ok(MemoryContext {
530 text,
531 count: given.len() as u32,
532 })
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn memory(id: &str, text: &str, pinned: bool) -> Memory {
541 Memory {
542 id: id.into(),
543 scope: MemoryScope::Project,
544 workspace: "acme".into(),
545 repo: None,
546 text: text.into(),
547 kind: MemoryKind::Fact,
548 source: MemorySource::default(),
549 created_by: "ana".into(),
550 pinned,
551 created_at: String::new(),
552 updated_at: String::new(),
553 last_used_at: None,
554 status: MemoryStatus::Kept,
555 confidence: None,
556 seen: 1,
557 }
558 }
559
560 fn repo() -> RepoPath {
561 RepoPath {
562 namespace: "acme".into(),
563 name: "web".into(),
564 }
565 }
566
567 #[test]
568 fn rename_statements_take_the_two_slugs() {
569 for sql in RENAMED {
570 assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}");
571 }
572 }
573
574 #[test]
575 fn nothing_kept_is_nothing_said() {
576 assert_eq!(compose(&[], &[], &repo(), 6000), (None, Vec::new()));
577 }
578
579 #[test]
580 fn levels_are_labelled_and_pinned_come_first() {
581 let workspace = [memory("w1", "We use pnpm everywhere.", false)];
582 let project = [memory("p1", "Tests need TZ=UTC.", false), memory("p2", "Never edit generated.rs.", true)];
583 let (text, given) = compose(&workspace, &project, &repo(), 6000);
584 let text = text.unwrap();
585 assert!(text.contains("Workspace memory (true across the acme workspace's projects)"));
586 assert!(text.contains("Project memory (acme/web)"));
587 assert!(text.find("Never edit").unwrap() < text.find("Tests need").unwrap());
588 assert_eq!(given, ["p2", "p1", "w1"]);
589 }
590
591 #[test]
592 fn the_budget_is_kept() {
593 let project: Vec<Memory> = (0..100).map(|n| memory(&format!("p{n}"), &"x".repeat(200), false)).collect();
594 let (text, given) = compose(&[], &project, &repo(), 3000);
595 assert!(text.unwrap().len() <= 3000);
596 assert!(given.len() < 100 && !given.is_empty());
597 }
598
599 #[test]
600 fn secrets_are_refused_with_a_way_out() {
601 // Joined at run time, so no whole key sits in the source for scanners to flag.
602 let key = ["ghp", "_", "abcdefghijklmnopqrstuvwxyz0123456789"].concat();
603 let refused = valid_text(&format!("deploy with {key}")).unwrap_err();
604 assert!(refused.contains("never holds secrets"));
605 assert_eq!(valid_text(" We use pnpm. ").unwrap(), "We use pnpm.");
606 assert!(valid_text(" ").is_err());
607 }
608}