pr_01m47d15m3e54sn21z27rpy5n9/services/work/src/memory.rs

608 lines25,201 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1//! 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)]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API42pub(crate) struct MemoryRow {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains43 id: String,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API44 pub(crate) scope: String,
45 pub(crate) scope_key: String,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains46 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>,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API59 // 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>,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains70}
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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API98 reference: row.source_ref,
99 evidence: row.evidence,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains100 },
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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API106 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),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains113 }
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!(
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API217 // 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 ?"
Agents and memory, checks and conflicts, profiles, slug renames, custom domains219 ))
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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API239 /// 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains249 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 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API312 // 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.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains315 let now = rfc3339(now_ms());
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API316 let print = g1t_contracts::capture::fingerprint(&text);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains317 let same = self
318 .db
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API319 .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()])?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains324 .first::<String>(Some("value"))
325 .await?;
326 if let Some(id) = same {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API327 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;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains329 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API336 .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'")
Agents and memory, checks and conflicts, profiles, slug renames, custom domains337 .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));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API363 // 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());
Agents and memory, checks and conflicts, profiles, slug renames, custom domains369 self.db
370 .prepare(
371 "INSERT INTO memories
372 (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API373 source_repo, source_number, created_by, pinned, created_at, updated_at,
374 status, fingerprint, sources, source_ref)
375 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains376 )
377 .bind(&[
378 id.as_str().into(),
379 a.scope.as_str().into(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API380 key.clone().into(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains381 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(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API395 print.as_str().into(),
396 serde_json::to_string(&[&reference])?.into(),
397 reference.as_str().into(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains398 ])?
399 .run()
400 .await?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API401 self.memory_changed(&id, &workspace, "kept", changed_repo.as_deref()).await;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains402 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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API413 let Some(before) = self.memory_row(&workspace, &a.id).await? else {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains414 return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found."));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API415 };
Agents and memory, checks and conflicts, profiles, slug renames, custom domains416 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 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API421 let print = text.as_deref().map(g1t_contracts::capture::fingerprint);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains422 self.db
423 .prepare(
424 "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API425 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains426 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()),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API432 print.map_or(JsValue::NULL, JsValue::from),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains433 rfc3339(now_ms()).into(),
434 a.id.as_str().into(),
435 workspace.as_str().into(),
436 ])?
437 .run()
438 .await?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API439 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;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains441 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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API452 let repo_id = self.memory_repo_id(&a.id).await?;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains453 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 {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API460 Some(_) => {
461 self.memory_changed(&a.id, &workspace, "deleted", repo_id.as_deref()).await;
462 Outcome::Ok(true)
463 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains464 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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API554 status: MemoryStatus::Kept,
555 confidence: None,
556 seen: 1,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains557 }
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() {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API601 // 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();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains604 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}