Skip to content
555 linesCodeBlameRaw
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//! A workspace's memory is members-only, since it can hold internal
7//! knowledge; a project's is for whoever can read the project, and changed
8//! by whoever can push to it. It never holds a secret: text that looks like a key or a token is refused.
9//! Agents read it with `recall` and add to it with `remember` (MCP), and
10//! people manage it on the Memory pages.
11
12use g1t_contracts::access::{self, Capability};
13use g1t_contracts::agents::*;
14use g1t_contracts::repos::{Repo, RepoPath};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id};
17use g1t_kit::now_ms;
18use serde::Deserialize;
19use worker::Result;
20use worker::wasm_bindgen::JsValue;
21
22use crate::Work;
23
24/// A workspace renamed: its runs and memories move to the slug it has now.
25/// `?1` is the current slug, `?2` a stale one (see `g1t_kit::rename`).
26pub(crate) const RENAMED: &[&str] = &[
27 "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2",
28 "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
29 "UPDATE memories SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
30 "UPDATE memories SET workspace = ?1 WHERE workspace = ?2",
31 "UPDATE memories SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
32 "UPDATE memories SET source_repo = ?1 || substr(source_repo, length(?2) + 1) WHERE substr(source_repo, 1, length(?2) + 1) = ?2 || '/'",
33];
34
35/// A repository transferred (see `g1t_kit::transfer`): its agent runs and
36/// its project's memory go with it, as its issues and pull requests do by
37/// id. `?1`/`?2` the path now and before, `?3`/`?4` the workspaces, `?5`
38/// the repository's id.
39pub(crate) const TRANSFERRED: &[&str] = &[
40 "UPDATE agent_runs SET workspace = ?3, repo = ?1 WHERE repo_id = ?5 AND repo = ?2",
41 "UPDATE memories SET workspace = ?3, repo = ?1 WHERE scope = 'project' AND scope_key = ?5 AND workspace = ?4",
42 "UPDATE memories SET source_repo = ?1 WHERE source_repo = ?2",
43];
44
45/// A workspace deleted: its own memory, guardrails and queued runs go.
46/// `?1` is its slug. Project memory went with each repository's transfer.
47pub(crate) const DELETED: &[&str] = &[
48 "DELETE FROM memories WHERE scope = 'workspace' AND scope_key = ?1",
49 "DELETE FROM guardrails WHERE scope = 'workspace' AND scope_key = ?1",
50];
51
52/// The most memories one project, or one workspace, keeps.
53const MAX_PER_SCOPE: u32 = 500;
54const DEFAULT_RECALL: u32 = 20;
55
56#[derive(Deserialize)]
57pub(crate) struct MemoryRow {
58 id: String,
59 pub(crate) scope: String,
60 workspace: String,
61 repo: Option<String>,
62 text: String,
63 kind: String,
64 source_kind: String,
65 source_run: Option<String>,
66 source_repo: Option<String>,
67 source_number: Option<u32>,
68 created_by: String,
69 pinned: u32,
70 created_at: String,
71 updated_at: String,
72 last_used_at: Option<String>,
73 // What automatic capture added (migration 0019); read for memories
74 // kept from before it was removed.
75 #[serde(default)]
76 status: Option<String>,
77 #[serde(default)]
78 confidence: Option<f64>,
79 #[serde(default)]
80 source_ref: Option<String>,
81 #[serde(default)]
82 evidence: Option<String>,
83 #[serde(default)]
84 sources: Option<String>,
85}
86
87fn path(text: &str) -> Option<RepoPath> {
88 let (namespace, name) = text.split_once('/')?;
89 Some(RepoPath {
90 namespace: namespace.to_owned(),
91 name: name.to_owned(),
92 })
93}
94
95impl From<MemoryRow> for Memory {
96 fn from(row: MemoryRow) -> Self {
97 Memory {
98 id: row.id,
99 scope: if row.scope == "workspace" {
100 MemoryScope::Workspace
101 } else {
102 MemoryScope::Project
103 },
104 workspace: row.workspace,
105 repo: row.repo.as_deref().and_then(path),
106 text: row.text,
107 kind: MemoryKind::parse(&row.kind).unwrap_or_default(),
108 source: MemorySource {
109 kind: row.source_kind,
110 run_id: row.source_run,
111 repo: row.source_repo.as_deref().and_then(path),
112 number: row.source_number,
113 reference: row.source_ref,
114 evidence: row.evidence,
115 },
116 created_by: row.created_by,
117 pinned: row.pinned != 0,
118 created_at: row.created_at,
119 updated_at: row.updated_at,
120 last_used_at: row.last_used_at,
121 status: row.status.as_deref().and_then(MemoryStatus::parse).unwrap_or_default(),
122 confidence: row.confidence,
123 seen: row
124 .sources
125 .as_deref()
126 .and_then(|sources| serde_json::from_str::<Vec<String>>(sources).ok())
127 .map_or(1, |sources| sources.len().max(1) as u32),
128 }
129 }
130}
131
132/// The text tidied, or why it cannot be kept.
133fn valid_text(text: &str) -> std::result::Result<String, String> {
134 let text = text.trim();
135 if text.is_empty() {
136 return Err("Write what should be remembered.".into());
137 }
138 if text.chars().count() > MAX_MEMORY_CHARS {
139 return Err(format!(
140 "Keep a memory to {MAX_MEMORY_CHARS} characters: one fact, convention, decision or gotcha each."
141 ));
142 }
143 if let Some(what) = secret_in(text) {
144 return Err(format!(
145 "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\"."
146 ));
147 }
148 Ok(text.to_owned())
149}
150
151/// Why a person cannot change a memory.
152const CHANGE_DENIED: &str = "Only members of the workspace can change its memory, and a project's only with the Write role on it.";
153
154fn denied<T>() -> Outcome<T> {
155 Outcome::fail(
156 FailureCode::Forbidden,
157 "Memory is for members of the workspace and its agents.",
158 )
159}
160
161/// Whether `actor` may change a memory of `workspace`: the workspace's own
162/// needs a member; a project's (`repo_id`, its repository) the Write role
163/// on that repository, as pushing to it does.
164fn may_write(actor: &User, workspace: &str, repo_id: Option<&str>) -> bool {
165 let able = actor.verified || actor.kind != PrincipalKind::User;
166 able && match repo_id {
167 None => actor.is_member(workspace),
168 // Write is never had through being public, so treating it as
169 // private changes nothing.
170 Some(id) => access::can(
171 Some(actor),
172 access::RepoRef { id, namespace: workspace, private: true },
173 Capability::Push,
174 ),
175 }
176}
177
178/// The order memories are given and listed in: pinned, then most recently
179/// used or changed.
180const ORDER: &str = "pinned DESC, COALESCE(last_used_at, updated_at) DESC, created_at DESC";
181
182/// A memory's text folded for comparing: lowercase words and digits, one
183/// space apart, so wording that differs only in case, punctuation or
184/// spacing is the same memory.
185fn fingerprint(text: &str) -> String {
186 text.to_lowercase()
187 .split(|c: char| !c.is_alphanumeric())
188 .filter(|word| !word.is_empty())
189 .collect::<Vec<_>>()
190 .join(" ")
191}
192
193impl Work {
194 async fn memories_of(&self, scope: MemoryScope, key: &str, limit: u32) -> Result<Vec<Memory>> {
195 Ok(self
196 .db
197 .prepare(format!(
198 // Only what is kept: candidates captured before are not shown.
199 "SELECT * FROM memories WHERE scope = ? AND scope_key = ? AND status = 'kept' ORDER BY {ORDER} LIMIT ?"
200 ))
201 .bind(&[scope.as_str().into(), key.into(), limit.into()])?
202 .all()
203 .await?
204 .results::<MemoryRow>()?
205 .into_iter()
206 .map(Memory::from)
207 .collect())
208 }
209
210 async fn memory_row(&self, workspace: &str, id: &str) -> Result<Option<Memory>> {
211 Ok(self
212 .db
213 .prepare("SELECT * FROM memories WHERE id = ? AND workspace = ?")
214 .bind(&[id.into(), workspace.into()])?
215 .first::<MemoryRow>(None)
216 .await?
217 .map(Memory::from))
218 }
219
220 /// The repository a project's memory is about; none for the workspace's.
221 async fn memory_repo_id(&self, id: &str) -> Result<Option<String>> {
222 self
223 .db
224 .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'")
225 .bind(&[id.into()])?
226 .first::<String>(Some("value"))
227 .await
228 }
229
230 async fn mark_used(&self, ids: &[String]) -> Result<()> {
231 if ids.is_empty() {
232 return Ok(());
233 }
234 self.db
235 .prepare("UPDATE memories SET last_used_at = ? WHERE id IN (SELECT value FROM json_each(?))")
236 .bind(&[rfc3339(now_ms()).into(), serde_json::to_string(ids)?.into()])?
237 .run()
238 .await?;
239 Ok(())
240 }
241
242 /// The project's repository, which must be in `workspace`.
243 async fn project_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
244 Ok(match self.repo(path, viewer).await? {
245 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
246 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
247 Outcome::Fail(failure) => Outcome::Fail(failure),
248 })
249 }
250
251 pub(crate) async fn list_memories(&self, a: ListMemoriesArgs) -> Result<Outcome<Memories>> {
252 let workspace = a.workspace.to_lowercase();
253 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
254 // Someone else who can read the project sees its memory alone.
255 if !member && a.repo.is_none() {
256 return Ok(denied());
257 }
258 let project = match &a.repo {
259 Some(path) => match self.project_repo(path, &a.viewer, &workspace).await? {
260 Outcome::Ok(repo) => self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?,
261 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
262 },
263 None => Vec::new(),
264 };
265 let workspace = if member {
266 self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?
267 } else {
268 Vec::new()
269 };
270 Ok(Outcome::Ok(Memories { project, workspace }))
271 }
272
273 pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> {
274 let workspace = a.workspace.to_lowercase();
275 if a.scope == MemoryScope::Workspace && !may_write(&a.actor, &workspace, None) {
276 return Ok(denied());
277 }
278 let text = match valid_text(&a.text) {
279 Ok(text) => text,
280 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
281 };
282 let viewer = Some(a.actor.clone());
283 let repo = match &a.repo {
284 Some(path) => match self.project_repo(path, &viewer, &workspace).await? {
285 Outcome::Ok(repo) => Some(repo),
286 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
287 },
288 None => None,
289 };
290 let key = match (a.scope, &repo) {
291 (MemoryScope::Workspace, _) => workspace.clone(),
292 (MemoryScope::Project, Some(repo)) => {
293 if !may_write(&a.actor, &workspace, Some(&repo.id)) {
294 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
295 }
296 repo.id.clone()
297 }
298 (MemoryScope::Project, None) => {
299 return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about."));
300 }
301 };
302 // The same thing remembered twice is one memory, freshened; written
303 // by hand, a candidate or a dismissed one with the same words is
304 // kept.
305 let now = rfc3339(now_ms());
306 let print = fingerprint(&text);
307 let same = self
308 .db
309 .prepare(
310 "UPDATE memories SET updated_at = ?, status = 'kept'
311 WHERE scope = ? AND scope_key = ? AND (text = ? OR fingerprint = ?) RETURNING id AS value",
312 )
313 .bind(&[now.as_str().into(), a.scope.as_str().into(), key.as_str().into(), text.as_str().into(), print.as_str().into()])?
314 .first::<String>(Some("value"))
315 .await?;
316 if let Some(id) = same {
317 return Ok(match self.memory_row(&workspace, &id).await? {
318 Some(memory) => Outcome::Ok(memory),
319 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
320 });
321 }
322 let count = self
323 .db
324 .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'")
325 .bind(&[a.scope.as_str().into(), key.as_str().into()])?
326 .first::<u32>(Some("value"))
327 .await?
328 .unwrap_or_default();
329 if count >= MAX_PER_SCOPE {
330 return Ok(Outcome::fail(
331 FailureCode::Conflict,
332 format!("This {} already keeps {MAX_PER_SCOPE} memories. Remove some that no longer hold first.", a.scope.as_str()),
333 ));
334 }
335 // Where it came from: a person, or an agent working for one.
336 let from = match (&repo, a.from_number) {
337 (Some(repo), Some(number)) => Some((repo, number)),
338 _ => None,
339 };
340 let source_kind = match a.actor.kind {
341 PrincipalKind::User => "person",
342 _ => "agent",
343 };
344 let id = new_id("mem", now_ms());
345 let repo_text = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
346 // Its source, as earlier memories record theirs.
347 let reference = format!("{source_kind}:{}", a.actor.username);
348 self.db
349 .prepare(
350 "INSERT INTO memories
351 (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run,
352 source_repo, source_number, created_by, pinned, created_at, updated_at,
353 status, fingerprint, sources, source_ref)
354 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
355 )
356 .bind(&[
357 id.as_str().into(),
358 a.scope.as_str().into(),
359 key.clone().into(),
360 workspace.as_str().into(),
361 // A workspace's memory belongs to no one project, though it
362 // remembers which it was learned in.
363 if a.scope == MemoryScope::Project { repo_text.clone().map_or(JsValue::NULL, JsValue::from) } else { JsValue::NULL },
364 text.into(),
365 a.kind.as_str().into(),
366 source_kind.into(),
367 JsValue::NULL,
368 repo_text.map_or(JsValue::NULL, JsValue::from),
369 from.map_or(JsValue::NULL, |(_, number)| number.into()),
370 a.actor.username.as_str().into(),
371 u32::from(a.pinned).into(),
372 now.as_str().into(),
373 now.as_str().into(),
374 print.as_str().into(),
375 serde_json::to_string(&[&reference])?.into(),
376 reference.as_str().into(),
377 ])?
378 .run()
379 .await?;
380 Ok(match self.memory_row(&workspace, &id).await? {
381 Some(memory) => Outcome::Ok(memory),
382 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
383 })
384 }
385
386 pub(crate) async fn update_memory(&self, a: UpdateMemoryArgs) -> Result<Outcome<Memory>> {
387 let workspace = a.workspace.to_lowercase();
388 let repo_id = self.memory_repo_id(&a.id).await?;
389 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
390 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
391 }
392 if self.memory_row(&workspace, &a.id).await?.is_none() {
393 return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found."));
394 }
395 let text = match a.text.as_deref().map(valid_text) {
396 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
397 Some(Ok(text)) => Some(text),
398 None => None,
399 };
400 let print = text.as_deref().map(fingerprint);
401 self.db
402 .prepare(
403 "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind),
404 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
405 WHERE id = ? AND workspace = ?",
406 )
407 .bind(&[
408 text.map_or(JsValue::NULL, JsValue::from),
409 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
410 a.pinned.map_or(JsValue::NULL, |pinned| u32::from(pinned).into()),
411 print.map_or(JsValue::NULL, JsValue::from),
412 rfc3339(now_ms()).into(),
413 a.id.as_str().into(),
414 workspace.as_str().into(),
415 ])?
416 .run()
417 .await?;
418 Ok(match self.memory_row(&workspace, &a.id).await? {
419 Some(memory) => Outcome::Ok(memory),
420 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
421 })
422 }
423
424 pub(crate) async fn delete_memory(&self, a: DeleteMemoryArgs) -> Result<Outcome<bool>> {
425 let workspace = a.workspace.to_lowercase();
426 let repo_id = self.memory_repo_id(&a.id).await?;
427 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
428 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
429 }
430 let gone = self
431 .db
432 .prepare("DELETE FROM memories WHERE id = ? AND workspace = ? RETURNING id AS value")
433 .bind(&[a.id.as_str().into(), workspace.as_str().into()])?
434 .first::<String>(Some("value"))
435 .await?;
436 Ok(match gone {
437 Some(_) => Outcome::Ok(true),
438 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
439 })
440 }
441
442 pub(crate) async fn recall(&self, a: RecallArgs) -> Result<Outcome<Memories>> {
443 let repo = match self.repo(&a.repo, &a.viewer).await? {
444 Outcome::Ok(repo) => repo,
445 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
446 };
447 let workspace = repo.namespace.to_lowercase();
448 // Found for the viewer, so they can read the project and its
449 // memory; the workspace's is its members'.
450 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
451 let words: Vec<String> = a
452 .query
453 .as_deref()
454 .unwrap_or_default()
455 .split_whitespace()
456 .map(str::to_lowercase)
457 .collect();
458 let limit = a.limit.unwrap_or(DEFAULT_RECALL).clamp(1, 100) as usize;
459 let matching = |memories: Vec<Memory>| -> Vec<Memory> {
460 memories
461 .into_iter()
462 .filter(|memory| {
463 let text = memory.text.to_lowercase();
464 words.iter().all(|word| text.contains(word.as_str()))
465 })
466 .take(limit)
467 .collect()
468 };
469 let found = Memories {
470 project: matching(self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?),
471 workspace: if member {
472 matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?)
473 } else {
474 Vec::new()
475 },
476 };
477 let ids: Vec<String> = found
478 .project
479 .iter()
480 .chain(&found.workspace)
481 .map(|memory| memory.id.clone())
482 .collect();
483 self.mark_used(&ids).await?;
484 Ok(Outcome::Ok(found))
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn transfer_and_deletion_statements_take_what_they_name() {
494 for sql in TRANSFERRED.iter().chain(crate::guardrails::TRANSFERRED) {
495 let n = g1t_kit::transfer::parameters(sql);
496 assert!((1..=5).contains(&n), "{sql}");
497 }
498 for sql in DELETED {
499 assert_eq!(g1t_kit::rename::parameters(sql), 1, "{sql}");
500 }
501 }
502
503 #[test]
504 fn rename_statements_take_the_two_slugs() {
505 for sql in RENAMED {
506 assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}");
507 }
508 }
509
510 #[test]
511 fn secrets_are_refused_with_a_way_out() {
512 // Joined at run time, so no whole key sits in the source for scanners to flag.
513 let key = ["ghp", "_", "abcdefghijklmnopqrstuvwxyz0123456789"].concat();
514 let refused = valid_text(&format!("deploy with {key}")).unwrap_err();
515 assert!(refused.contains("never holds secrets"));
516 assert_eq!(valid_text(" We use pnpm. ").unwrap(), "We use pnpm.");
517 assert!(valid_text(" ").is_err());
518 }
519
520 fn person(base: Option<access::BasePermission>, grants: &[(&str, access::RepoRole)], member: bool) -> User {
521 User {
522 id: "usr_1".into(),
523 username: "ana".into(),
524 verified: true,
525 workspaces: if member {
526 vec![g1t_contracts::Membership { base_permission: base, ..g1t_contracts::Membership::member("acme") }]
527 } else {
528 Vec::new()
529 },
530 grants: grants
531 .iter()
532 .map(|(id, role)| access::RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role, team: None })
533 .collect(),
534 ..User::default()
535 }
536 }
537
538 #[test]
539 fn a_workspaces_memory_is_its_members_and_a_projects_needs_write() {
540 use access::{BasePermission, RepoRole};
541 let member = person(None, &[], true);
542 assert!(may_write(&member, "acme", None));
543 assert!(may_write(&member, "acme", Some("rep_1")));
544 // A reader may not change a project's memory, nor may an outsider.
545 let reader = person(Some(BasePermission::Read), &[], true);
546 assert!(may_write(&reader, "acme", None));
547 assert!(!may_write(&reader, "acme", Some("rep_1")));
548 let outsider = person(None, &[("rep_1", RepoRole::Write)], false);
549 assert!(may_write(&outsider, "acme", Some("rep_1")));
550 assert!(!may_write(&outsider, "acme", Some("rep_2")));
551 assert!(!may_write(&outsider, "acme", None));
552 let unverified = User { verified: false, ..person(None, &[], true) };
553 assert!(!may_write(&unverified, "acme", None));
554 }
555}