Skip to content

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.sh
733 linesCodeBlameRaw

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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! 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. Every
9//! g1t agent run is given it (`memory_context`): pinned first, then what
10//! was used most recently, within a size budget.
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;
23use crate::runs::member_of;
24
25/// A workspace renamed: its runs and memories move to the slug it has now.
26/// `?1` is the current slug, `?2` a stale one (see `g1t_kit::rename`).
27pub(crate) const RENAMED: &[&str] = &[
28 "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2",
29 "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
30 "UPDATE memories SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
31 "UPDATE memories SET workspace = ?1 WHERE workspace = ?2",
32 "UPDATE memories SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
33 "UPDATE memories SET source_repo = ?1 || substr(source_repo, length(?2) + 1) WHERE substr(source_repo, 1, length(?2) + 1) = ?2 || '/'",
34];
35
36/// A repository transferred (see `g1t_kit::transfer`): its agent runs and
37/// its project's memory go with it, as its issues and pull requests do by
38/// id. `?1`/`?2` the path now and before, `?3`/`?4` the workspaces, `?5`
39/// the repository's id.
40pub(crate) const TRANSFERRED: &[&str] = &[
41 "UPDATE agent_runs SET workspace = ?3, repo = ?1 WHERE repo_id = ?5 AND repo = ?2",
42 "UPDATE memories SET workspace = ?3, repo = ?1 WHERE scope = 'project' AND scope_key = ?5 AND workspace = ?4",
43 "UPDATE memories SET source_repo = ?1 WHERE source_repo = ?2",
44];
45
46/// A workspace deleted: its own memory, guardrails and queued runs go.
47/// `?1` is its slug. Project memory went with each repository's transfer.
48pub(crate) const DELETED: &[&str] = &[
49 "DELETE FROM memories WHERE scope = 'workspace' AND scope_key = ?1",
50 "DELETE FROM guardrails WHERE scope = 'workspace' AND scope_key = ?1",
51 "DELETE FROM agent_waits WHERE workspace = ?1",
52];
53
54/// The most memories one project, or one workspace, keeps.
55const MAX_PER_SCOPE: u32 = 500;
56/// What an agent is given by default, in characters.
57const DEFAULT_BUDGET: u32 = 6000;
58const MAX_BUDGET: u32 = 20_000;
59const DEFAULT_RECALL: u32 = 20;
60
61#[derive(Deserialize)]
62pub(crate) struct MemoryRow {
63 id: String,
64 pub(crate) scope: String,
65 pub(crate) scope_key: String,
66 workspace: String,
67 repo: Option<String>,
68 text: String,
69 kind: String,
70 source_kind: String,
71 source_run: Option<String>,
72 source_repo: Option<String>,
73 source_number: Option<u32>,
74 created_by: String,
75 pinned: u32,
76 created_at: String,
77 updated_at: String,
78 last_used_at: Option<String>,
79 // What capture adds (capture.rs, migration 0019).
80 #[serde(default)]
81 status: Option<String>,
82 #[serde(default)]
83 confidence: Option<f64>,
84 #[serde(default)]
85 source_ref: Option<String>,
86 #[serde(default)]
87 evidence: Option<String>,
88 #[serde(default)]
89 sources: Option<String>,
90}
91
92fn path(text: &str) -> Option<RepoPath> {
93 let (namespace, name) = text.split_once('/')?;
94 Some(RepoPath {
95 namespace: namespace.to_owned(),
96 name: name.to_owned(),
97 })
98}
99
100impl From<MemoryRow> for Memory {
101 fn from(row: MemoryRow) -> Self {
102 Memory {
103 id: row.id,
104 scope: if row.scope == "workspace" {
105 MemoryScope::Workspace
106 } else {
107 MemoryScope::Project
108 },
109 workspace: row.workspace,
110 repo: row.repo.as_deref().and_then(path),
111 text: row.text,
112 kind: MemoryKind::parse(&row.kind).unwrap_or_default(),
113 source: MemorySource {
114 kind: row.source_kind,
115 run_id: row.source_run,
116 repo: row.source_repo.as_deref().and_then(path),
117 number: row.source_number,
118 reference: row.source_ref,
119 evidence: row.evidence,
120 },
121 created_by: row.created_by,
122 pinned: row.pinned != 0,
123 created_at: row.created_at,
124 updated_at: row.updated_at,
125 last_used_at: row.last_used_at,
126 status: row.status.as_deref().and_then(MemoryStatus::parse).unwrap_or_default(),
127 confidence: row.confidence,
128 seen: row
129 .sources
130 .as_deref()
131 .and_then(|sources| serde_json::from_str::<Vec<String>>(sources).ok())
132 .map_or(1, |sources| sources.len().max(1) as u32),
133 }
134 }
135}
136
137/// The text tidied, or why it cannot be kept.
138fn valid_text(text: &str) -> std::result::Result<String, String> {
139 let text = text.trim();
140 if text.is_empty() {
141 return Err("Write what should be remembered.".into());
142 }
143 if text.chars().count() > MAX_MEMORY_CHARS {
144 return Err(format!(
145 "Keep a memory to {MAX_MEMORY_CHARS} characters: one fact, convention, decision or gotcha each."
146 ));
147 }
148 if let Some(what) = secret_in(text) {
149 return Err(format!(
150 "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\"."
151 ));
152 }
153 Ok(text.to_owned())
154}
155
156/// Why a person cannot change a memory.
157const CHANGE_DENIED: &str = "Only members of the workspace can change its memory, and a project's only with the Write role on it.";
158
159fn denied<T>() -> Outcome<T> {
160 Outcome::fail(
161 FailureCode::Forbidden,
162 "Memory is for members of the workspace and its agents.",
163 )
164}
165
166/// Whether `actor` may change a memory of `workspace`: the workspace's own
167/// needs a member; a project's (`repo_id`, its repository) the Write role
168/// on that repository, as pushing to it does.
169fn may_write(actor: &User, workspace: &str, repo_id: Option<&str>) -> bool {
170 let able = actor.verified || actor.kind != PrincipalKind::User;
171 able && match repo_id {
172 None => actor.is_member(workspace),
173 // Write is never had through being public, so treating it as
174 // private changes nothing.
175 Some(id) => access::can(
176 Some(actor),
177 access::RepoRef { id, namespace: workspace, private: true },
178 Capability::Push,
179 ),
180 }
181}
182
183/// The order memories are given and listed in: pinned, then most recently
184/// used or changed.
185const ORDER: &str = "pinned DESC, COALESCE(last_used_at, updated_at) DESC, created_at DESC";
186
187/// What an agent is told about memory, ahead of the memories themselves.
188const 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.";
189
190/// How an agent is told to add to memory.
191const 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.";
192
193/// The context an agent gets: as many memories as fit in `budget`, each
194/// level labelled, and the ids of those given.
195fn compose(workspace: &[Memory], project: &[Memory], repo: &RepoPath, budget: usize) -> (Option<String>, Vec<String>) {
196 let line = |memory: &Memory| {
197 format!(
198 "- [{}{}] {}",
199 memory.kind.as_str(),
200 if memory.pinned { ", pinned" } else { "" },
201 memory.text.replace('\n', " ")
202 )
203 };
204 let mut used = PREAMBLE.len() + HOW_TO_REMEMBER.len() + 200;
205 let mut given = Vec::new();
206 let mut sections = Vec::new();
207 // Pinned memories of either level go in before anything else does.
208 let mut take = |memories: &[Memory], pinned: bool, out: &mut Vec<String>| {
209 for memory in memories.iter().filter(|memory| memory.pinned == pinned) {
210 let text = line(memory);
211 if used + text.len() + 1 > budget {
212 continue;
213 }
214 used += text.len() + 1;
215 given.push(memory.id.clone());
216 out.push(text);
217 }
218 };
219 let (mut ws, mut own) = (Vec::new(), Vec::new());
220 take(workspace, true, &mut ws);
221 take(project, true, &mut own);
222 take(project, false, &mut own);
223 take(workspace, false, &mut ws);
224 if ws.is_empty() && own.is_empty() {
225 return (None, given);
226 }
227 sections.push(PREAMBLE.to_owned());
228 if !ws.is_empty() {
229 sections.push(format!(
230 "Workspace memory (true across the {} workspace's projects):\n{}",
231 repo.namespace,
232 ws.join("\n")
233 ));
234 }
235 if !own.is_empty() {
236 sections.push(format!(
237 "Project memory ({}/{}):\n{}",
238 repo.namespace,
239 repo.name,
240 own.join("\n")
241 ));
242 }
243 sections.push(HOW_TO_REMEMBER.to_owned());
244 (Some(sections.join("\n\n")), given)
245}
246
247impl Work {
248 async fn memories_of(&self, scope: MemoryScope, key: &str, limit: u32) -> Result<Vec<Memory>> {
249 Ok(self
250 .db
251 .prepare(format!(
252 // Only what is kept: candidates wait for review (capture.rs).
253 "SELECT * FROM memories WHERE scope = ? AND scope_key = ? AND status = 'kept' ORDER BY {ORDER} LIMIT ?"
254 ))
255 .bind(&[scope.as_str().into(), key.into(), limit.into()])?
256 .all()
257 .await?
258 .results::<MemoryRow>()?
259 .into_iter()
260 .map(Memory::from)
261 .collect())
262 }
263
264 async fn memory_row(&self, workspace: &str, id: &str) -> Result<Option<Memory>> {
265 Ok(self
266 .db
267 .prepare("SELECT * FROM memories WHERE id = ? AND workspace = ?")
268 .bind(&[id.into(), workspace.into()])?
269 .first::<MemoryRow>(None)
270 .await?
271 .map(Memory::from))
272 }
273
274 /// The repository a project's memory is about; none for the workspace's.
275 async fn memory_repo_id(&self, id: &str) -> Result<Option<String>> {
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0276 self
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor277 .db
278 .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'")
279 .bind(&[id.into()])?
280 .first::<String>(Some("value"))
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0281 .await
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor282 }
283
284 async fn mark_used(&self, ids: &[String]) -> Result<()> {
285 if ids.is_empty() {
286 return Ok(());
287 }
288 self.db
289 .prepare("UPDATE memories SET last_used_at = ? WHERE id IN (SELECT value FROM json_each(?))")
290 .bind(&[rfc3339(now_ms()).into(), serde_json::to_string(ids)?.into()])?
291 .run()
292 .await?;
293 Ok(())
294 }
295
296 /// The project's repository, which must be in `workspace`.
297 async fn project_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
298 Ok(match self.repo(path, viewer).await? {
299 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
300 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
301 Outcome::Fail(failure) => Outcome::Fail(failure),
302 })
303 }
304
305 pub(crate) async fn list_memories(&self, a: ListMemoriesArgs) -> Result<Outcome<Memories>> {
306 let workspace = a.workspace.to_lowercase();
307 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
308 // Someone else who can read the project sees its memory alone.
309 if !member && a.repo.is_none() {
310 return Ok(denied());
311 }
312 let project = match &a.repo {
313 Some(path) => match self.project_repo(path, &a.viewer, &workspace).await? {
314 Outcome::Ok(repo) => self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?,
315 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
316 },
317 None => Vec::new(),
318 };
319 let workspace = if member {
320 self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?
321 } else {
322 Vec::new()
323 };
324 Ok(Outcome::Ok(Memories { project, workspace }))
325 }
326
327 pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> {
328 let workspace = a.workspace.to_lowercase();
329 if a.scope == MemoryScope::Workspace && !may_write(&a.actor, &workspace, None) {
330 return Ok(denied());
331 }
332 let text = match valid_text(&a.text) {
333 Ok(text) => text,
334 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
335 };
336 let viewer = Some(a.actor.clone());
337 let repo = match &a.repo {
338 Some(path) => match self.project_repo(path, &viewer, &workspace).await? {
339 Outcome::Ok(repo) => Some(repo),
340 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
341 },
342 None => None,
343 };
344 let key = match (a.scope, &repo) {
345 (MemoryScope::Workspace, _) => workspace.clone(),
346 (MemoryScope::Project, Some(repo)) => {
347 if !may_write(&a.actor, &workspace, Some(&repo.id)) {
348 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
349 }
350 repo.id.clone()
351 }
352 (MemoryScope::Project, None) => {
353 return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about."));
354 }
355 };
356 // The same thing remembered twice is one memory, freshened; written
357 // by hand, a candidate or a dismissed one with the same words is
358 // kept.
359 let now = rfc3339(now_ms());
360 let print = g1t_contracts::capture::fingerprint(&text);
361 let same = self
362 .db
363 .prepare(
364 "UPDATE memories SET updated_at = ?, status = 'kept'
365 WHERE scope = ? AND scope_key = ? AND (text = ? OR fingerprint = ?) RETURNING id AS value",
366 )
367 .bind(&[now.as_str().into(), a.scope.as_str().into(), key.as_str().into(), text.as_str().into(), print.as_str().into()])?
368 .first::<String>(Some("value"))
369 .await?;
370 if let Some(id) = same {
371 let repo_id = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
372 self.memory_changed(&id, &workspace, "kept", repo_id.as_deref()).await;
373 return Ok(match self.memory_row(&workspace, &id).await? {
374 Some(memory) => Outcome::Ok(memory),
375 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
376 });
377 }
378 let count = self
379 .db
380 .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'")
381 .bind(&[a.scope.as_str().into(), key.as_str().into()])?
382 .first::<u32>(Some("value"))
383 .await?
384 .unwrap_or_default();
385 if count >= MAX_PER_SCOPE {
386 return Ok(Outcome::fail(
387 FailureCode::Conflict,
388 format!("This {} already keeps {MAX_PER_SCOPE} memories. Remove some that no longer hold first.", a.scope.as_str()),
389 ));
390 }
391 // Where it came from: a person, or an agent, and the run it was in.
392 let from = match (&repo, a.from_number) {
393 (Some(repo), Some(number)) => Some((repo, number)),
394 _ => None,
395 };
396 let run = match (a.actor.kind, from) {
397 (PrincipalKind::Agent, Some((repo, number))) => self.active_run_on(&repo.id, number).await?,
398 _ => None,
399 };
400 let source_kind = match (a.actor.kind, &run) {
401 (PrincipalKind::User, _) => "person",
402 (_, Some(_)) => "run",
403 _ => "agent",
404 };
405 let id = new_id("mem", now_ms());
406 let repo_text = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
407 // Its source, as capture counts sources (capture.rs).
408 let reference = match &run {
409 Some(run) => format!("run:{run}"),
410 None => format!("{source_kind}:{}", a.actor.username),
411 };
412 let changed_repo = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
413 self.db
414 .prepare(
415 "INSERT INTO memories
416 (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run,
417 source_repo, source_number, created_by, pinned, created_at, updated_at,
418 status, fingerprint, sources, source_ref)
419 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
420 )
421 .bind(&[
422 id.as_str().into(),
423 a.scope.as_str().into(),
424 key.clone().into(),
425 workspace.as_str().into(),
426 // A workspace's memory belongs to no one project, though it
427 // remembers which it was learned in.
428 if a.scope == MemoryScope::Project { repo_text.clone().map_or(JsValue::NULL, JsValue::from) } else { JsValue::NULL },
429 text.into(),
430 a.kind.as_str().into(),
431 source_kind.into(),
432 run.map_or(JsValue::NULL, JsValue::from),
433 repo_text.map_or(JsValue::NULL, JsValue::from),
434 from.map_or(JsValue::NULL, |(_, number)| number.into()),
435 a.actor.username.as_str().into(),
436 u32::from(a.pinned).into(),
437 now.as_str().into(),
438 now.as_str().into(),
439 print.as_str().into(),
440 serde_json::to_string(&[&reference])?.into(),
441 reference.as_str().into(),
442 ])?
443 .run()
444 .await?;
445 self.memory_changed(&id, &workspace, "kept", changed_repo.as_deref()).await;
446 Ok(match self.memory_row(&workspace, &id).await? {
447 Some(memory) => Outcome::Ok(memory),
448 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
449 })
450 }
451
452 pub(crate) async fn update_memory(&self, a: UpdateMemoryArgs) -> Result<Outcome<Memory>> {
453 let workspace = a.workspace.to_lowercase();
454 let repo_id = self.memory_repo_id(&a.id).await?;
455 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
456 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
457 }
458 let Some(before) = self.memory_row(&workspace, &a.id).await? else {
459 return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found."));
460 };
461 let text = match a.text.as_deref().map(valid_text) {
462 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
463 Some(Ok(text)) => Some(text),
464 None => None,
465 };
466 let print = text.as_deref().map(g1t_contracts::capture::fingerprint);
467 self.db
468 .prepare(
469 "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind),
470 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
471 WHERE id = ? AND workspace = ?",
472 )
473 .bind(&[
474 text.map_or(JsValue::NULL, JsValue::from),
475 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
476 a.pinned.map_or(JsValue::NULL, |pinned| u32::from(pinned).into()),
477 print.map_or(JsValue::NULL, JsValue::from),
478 rfc3339(now_ms()).into(),
479 a.id.as_str().into(),
480 workspace.as_str().into(),
481 ])?
482 .run()
483 .await?;
484 self.memory_changed(&a.id, &workspace, before.status.as_str(), repo_id.as_deref()).await;
485 Ok(match self.memory_row(&workspace, &a.id).await? {
486 Some(memory) => Outcome::Ok(memory),
487 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
488 })
489 }
490
491 pub(crate) async fn delete_memory(&self, a: DeleteMemoryArgs) -> Result<Outcome<bool>> {
492 let workspace = a.workspace.to_lowercase();
493 let repo_id = self.memory_repo_id(&a.id).await?;
494 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
495 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
496 }
497 let gone = self
498 .db
499 .prepare("DELETE FROM memories WHERE id = ? AND workspace = ? RETURNING id AS value")
500 .bind(&[a.id.as_str().into(), workspace.as_str().into()])?
501 .first::<String>(Some("value"))
502 .await?;
503 Ok(match gone {
504 Some(_) => {
505 self.memory_changed(&a.id, &workspace, "deleted", repo_id.as_deref()).await;
506 Outcome::Ok(true)
507 }
508 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
509 })
510 }
511
512 pub(crate) async fn recall(&self, a: RecallArgs) -> Result<Outcome<Memories>> {
513 let repo = match self.repo(&a.repo, &a.viewer).await? {
514 Outcome::Ok(repo) => repo,
515 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
516 };
517 let workspace = repo.namespace.to_lowercase();
518 // Found for the viewer, so they can read the project and its
519 // memory; the workspace's is its members'.
520 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
521 let words: Vec<String> = a
522 .query
523 .as_deref()
524 .unwrap_or_default()
525 .split_whitespace()
526 .map(str::to_lowercase)
527 .collect();
528 let limit = a.limit.unwrap_or(DEFAULT_RECALL).clamp(1, 100) as usize;
529 let matching = |memories: Vec<Memory>| -> Vec<Memory> {
530 memories
531 .into_iter()
532 .filter(|memory| {
533 let text = memory.text.to_lowercase();
534 words.iter().all(|word| text.contains(word.as_str()))
535 })
536 .take(limit)
537 .collect()
538 };
539 let found = Memories {
540 project: matching(self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?),
541 workspace: if member {
542 matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?)
543 } else {
544 Vec::new()
545 },
546 };
547 let ids: Vec<String> = found
548 .project
549 .iter()
550 .chain(&found.workspace)
551 .map(|memory| memory.id.clone())
552 .collect();
553 self.mark_used(&ids).await?;
554 Ok(Outcome::Ok(found))
555 }
556
557 pub(crate) async fn memory_context(&self, a: MemoryContextArgs) -> Result<MemoryContext> {
558 let service = User {
559 id: "g1t_runner".into(),
560 username: "g1t".into(),
561 ..User::default()
562 };
563 let Outcome::Ok(repo) = self.repo(&a.repo, &member_of(&service, &a.repo.namespace)).await? else {
564 return Ok(MemoryContext::default());
565 };
566 // The workspace's memory is its members': a run for an outside
567 // collaborator is told only the project's.
568 let workspace = if workspace_memory_for(a.requester.as_ref(), &repo.namespace) {
569 self.memories_of(MemoryScope::Workspace, &repo.namespace.to_lowercase(), MAX_PER_SCOPE)
570 .await?
571 } else {
572 Vec::new()
573 };
574 let project = self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?;
575 let budget = a.budget.unwrap_or(DEFAULT_BUDGET).clamp(500, MAX_BUDGET) as usize;
576 let path = RepoPath {
577 namespace: repo.namespace.clone(),
578 name: repo.name.clone(),
579 };
580 let (text, given) = compose(&workspace, &project, &path, budget);
581 self.mark_used(&given).await?;
582 Ok(MemoryContext {
583 text,
584 count: given.len() as u32,
585 })
586 }
587}
588
589/// Whether a run for `requester` in `namespace` is told the workspace's
590/// memory: for its members, and for the workspace's own steps (no
591/// requester), never for an outside collaborator.
592fn workspace_memory_for(requester: Option<&User>, namespace: &str) -> bool {
593 requester.is_none_or(|user| user.is_member(&namespace.to_lowercase()))
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn workspace_memory_is_for_members_runs_only() {
602 let member = User {
603 id: "u_ana".into(),
604 username: "ana".into(),
605 workspaces: vec![g1t_contracts::Membership::member("acme")],
606 ..User::default()
607 };
608 let outside = User {
609 id: "u_oli".into(),
610 username: "oli".into(),
611 ..User::default()
612 };
613 assert!(workspace_memory_for(None, "acme"));
614 assert!(workspace_memory_for(Some(&member), "Acme"));
615 assert!(!workspace_memory_for(Some(&outside), "acme"));
616 }
617
618 #[test]
619 fn transfer_and_deletion_statements_take_what_they_name() {
620 for sql in TRANSFERRED.iter().chain(crate::guardrails::TRANSFERRED) {
621 let n = g1t_kit::transfer::parameters(sql);
622 assert!((1..=5).contains(&n), "{sql}");
623 }
624 for sql in DELETED {
625 assert_eq!(g1t_kit::rename::parameters(sql), 1, "{sql}");
626 }
627 }
628
629 fn memory(id: &str, text: &str, pinned: bool) -> Memory {
630 Memory {
631 id: id.into(),
632 scope: MemoryScope::Project,
633 workspace: "acme".into(),
634 repo: None,
635 text: text.into(),
636 kind: MemoryKind::Fact,
637 source: MemorySource::default(),
638 created_by: "ana".into(),
639 pinned,
640 created_at: String::new(),
641 updated_at: String::new(),
642 last_used_at: None,
643 status: MemoryStatus::Kept,
644 confidence: None,
645 seen: 1,
646 }
647 }
648
649 fn repo() -> RepoPath {
650 RepoPath {
651 namespace: "acme".into(),
652 name: "web".into(),
653 }
654 }
655
656 #[test]
657 fn rename_statements_take_the_two_slugs() {
658 for sql in RENAMED {
659 assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}");
660 }
661 }
662
663 #[test]
664 fn nothing_kept_is_nothing_said() {
665 assert_eq!(compose(&[], &[], &repo(), 6000), (None, Vec::new()));
666 }
667
668 #[test]
669 fn levels_are_labelled_and_pinned_come_first() {
670 let workspace = [memory("w1", "We use pnpm everywhere.", false)];
671 let project = [memory("p1", "Tests need TZ=UTC.", false), memory("p2", "Never edit generated.rs.", true)];
672 let (text, given) = compose(&workspace, &project, &repo(), 6000);
673 let text = text.unwrap();
674 assert!(text.contains("Workspace memory (true across the acme workspace's projects)"));
675 assert!(text.contains("Project memory (acme/web)"));
676 assert!(text.find("Never edit").unwrap() < text.find("Tests need").unwrap());
677 assert_eq!(given, ["p2", "p1", "w1"]);
678 }
679
680 #[test]
681 fn the_budget_is_kept() {
682 let project: Vec<Memory> = (0..100).map(|n| memory(&format!("p{n}"), &"x".repeat(200), false)).collect();
683 let (text, given) = compose(&[], &project, &repo(), 3000);
684 assert!(text.unwrap().len() <= 3000);
685 assert!(given.len() < 100 && !given.is_empty());
686 }
687
688 #[test]
689 fn secrets_are_refused_with_a_way_out() {
690 // Joined at run time, so no whole key sits in the source for scanners to flag.
691 let key = ["ghp", "_", "abcdefghijklmnopqrstuvwxyz0123456789"].concat();
692 let refused = valid_text(&format!("deploy with {key}")).unwrap_err();
693 assert!(refused.contains("never holds secrets"));
694 assert_eq!(valid_text(" We use pnpm. ").unwrap(), "We use pnpm.");
695 assert!(valid_text(" ").is_err());
696 }
697
698 fn person(base: Option<access::BasePermission>, grants: &[(&str, access::RepoRole)], member: bool) -> User {
699 User {
700 id: "usr_1".into(),
701 username: "ana".into(),
702 verified: true,
703 workspaces: if member {
704 vec![g1t_contracts::Membership { base_permission: base, ..g1t_contracts::Membership::member("acme") }]
705 } else {
706 Vec::new()
707 },
708 grants: grants
709 .iter()
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar710 .map(|(id, role)| access::RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role, team: None })
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor711 .collect(),
712 ..User::default()
713 }
714 }
715
716 #[test]
717 fn a_workspaces_memory_is_its_members_and_a_projects_needs_write() {
718 use access::{BasePermission, RepoRole};
719 let member = person(None, &[], true);
720 assert!(may_write(&member, "acme", None));
721 assert!(may_write(&member, "acme", Some("rep_1")));
722 // A reader may not change a project's memory, nor may an outsider.
723 let reader = person(Some(BasePermission::Read), &[], true);
724 assert!(may_write(&reader, "acme", None));
725 assert!(!may_write(&reader, "acme", Some("rep_1")));
726 let outsider = person(None, &[("rep_1", RepoRole::Write)], false);
727 assert!(may_write(&outsider, "acme", Some("rep_1")));
728 assert!(!may_write(&outsider, "acme", Some("rep_2")));
729 assert!(!may_write(&outsider, "acme", None));
730 let unverified = User { verified: false, ..person(None, &[], true) };
731 assert!(!may_write(&unverified, "acme", None));
732 }
733}

This file's history is long; its oldest lines are credited to the oldest commit read.