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

733 lines30,758 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//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6//! 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains9//! g1t agent run is given it (`memory_context`): pinned first, then what
10//! was used most recently, within a size budget.
11
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12use g1t_contracts::access::{self, Capability};
Agents and memory, checks and conflicts, profiles, slug renames, custom domains13use 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36/// 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54/// 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)]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API62pub(crate) struct MemoryRow {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains63 id: String,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API64 pub(crate) scope: String,
65 pub(crate) scope_key: String,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains66 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>,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API79 // 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>,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains90}
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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API118 reference: row.source_ref,
119 evidence: row.evidence,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains120 },
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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API126 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),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains133 }
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look156/// 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains159fn denied<T>() -> Outcome<T> {
160 Outcome::fail(
161 FailureCode::Forbidden,
162 "Memory is for members of the workspace and its agents.",
163 )
164}
165
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166/// 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 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains181}
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!(
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API252 // 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 ?"
Agents and memory, checks and conflicts, profiles, slug renames, custom domains254 ))
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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API274 /// 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>> {
276 Ok(self
277 .db
278 .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'")
279 .bind(&[id.into()])?
280 .first::<String>(Some("value"))
281 .await?)
282 }
283
Agents and memory, checks and conflicts, profiles, slug renames, custom domains284 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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look307 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() {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains310 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 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look319 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 }))
Agents and memory, checks and conflicts, profiles, slug renames, custom domains325 }
326
327 pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> {
328 let workspace = a.workspace.to_lowercase();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look329 if a.scope == MemoryScope::Workspace && !may_write(&a.actor, &workspace, None) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains330 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(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look346 (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 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains352 (MemoryScope::Project, None) => {
353 return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about."));
354 }
355 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API356 // 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.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains359 let now = rfc3339(now_ms());
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API360 let print = g1t_contracts::capture::fingerprint(&text);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains361 let same = self
362 .db
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API363 .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()])?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains368 .first::<String>(Some("value"))
369 .await?;
370 if let Some(id) = same {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API371 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;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains373 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API380 .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 domains381 .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));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API407 // 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());
Agents and memory, checks and conflicts, profiles, slug renames, custom domains413 self.db
414 .prepare(
415 "INSERT INTO memories
416 (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 API417 source_repo, source_number, created_by, pinned, created_at, updated_at,
418 status, fingerprint, sources, source_ref)
419 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains420 )
421 .bind(&[
422 id.as_str().into(),
423 a.scope.as_str().into(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API424 key.clone().into(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains425 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(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API439 print.as_str().into(),
440 serde_json::to_string(&[&reference])?.into(),
441 reference.as_str().into(),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains442 ])?
443 .run()
444 .await?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API445 self.memory_changed(&id, &workspace, "kept", changed_repo.as_deref()).await;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains446 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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look454 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));
Agents and memory, checks and conflicts, profiles, slug renames, custom domains457 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API458 let Some(before) = self.memory_row(&workspace, &a.id).await? else {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains459 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 API460 };
Agents and memory, checks and conflicts, profiles, slug renames, custom domains461 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 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API466 let print = text.as_deref().map(g1t_contracts::capture::fingerprint);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains467 self.db
468 .prepare(
469 "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 API470 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains471 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()),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API477 print.map_or(JsValue::NULL, JsValue::from),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains478 rfc3339(now_ms()).into(),
479 a.id.as_str().into(),
480 workspace.as_str().into(),
481 ])?
482 .run()
483 .await?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API484 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 domains485 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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look493 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));
Agents and memory, checks and conflicts, profiles, slug renames, custom domains496 }
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 {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API504 Some(_) => {
505 self.memory_changed(&a.id, &workspace, "deleted", repo_id.as_deref()).await;
506 Outcome::Ok(true)
507 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains508 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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look518 // 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));
Agents and memory, checks and conflicts, profiles, slug renames, custom domains521 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?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look541 workspace: if member {
542 matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?)
543 } else {
544 Vec::new()
545 },
Agents and memory, checks and conflicts, profiles, slug renames, custom domains546 };
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 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look566 // 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 };
Agents and memory, checks and conflicts, profiles, slug renames, custom domains574 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look589/// 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains596#[cfg(test)]
597mod tests {
598 use super::*;
599
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look600 #[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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains629 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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API643 status: MemoryStatus::Kept,
644 confidence: None,
645 seen: 1,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains646 }
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() {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API690 // 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();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains693 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look697
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()
710 .map(|(id, role)| access::RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role })
711 .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 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains733}