The landing page no longer promises a workspace in a minute: sign-ups are about to be held for invites, so both buttons say Get started and still lead to /register. Artifacts drops its Coming soon tag on the page, since docs work today, and its card links to how Artifacts work rather than what they will do; only slides, designs and dashboards are still to come, as the card says. The g1t CLI is named beside MCP and the API wherever the page says how a coding agent works on g1t, with its install line under the MCP address and a button to its guide, and the forge's list includes it. Chat's coming-soon list says chat mentions in Notifications, as the Chat guide does, rather than approvals, which Notifications already has. The tour's preview address has the form deployments give one (web-git-csv-acme.g1t.page), the step pills stay on one line on a phone, and the long git and curl lines wrap at their slashes there instead of hiding their ends.
13 files+29−40770/13 viewed
| 226 | 226 | name: "Artifacts", | |
| 227 | 227 | about: | |
| 228 | 228 | "Docs now, and slides, designs and dashboards next, made together live.", | |
| 229 | − | soon: true, | |
| 230 | 229 | to: `${DOCS}/guides/artifacts/`, | |
| 231 | 230 | }, | |
| 232 | 231 | { | |
| 233 | 232 | icon: <Bot size={18} />, | |
| 234 | 233 | name: "Your coding agents", | |
| 235 | 234 | about: | |
| 236 | − | "Claude Code, Codex, Cursor or any MCP client, working as you with the access you give them.", | |
| 235 | + | "Claude Code, Codex, Cursor or any MCP client, working as you through MCP, the g1t CLI or the API, with only the access you give them.", | |
| 237 | 236 | to: `${DOCS}/guides/bring-your-own-agent/`, | |
| 238 | 237 | }, | |
| 239 | 238 | ]; | |
| ⋯ | |||
| 308 | 307 | "Import from any git host", | |
| 309 | 308 | "REST API and OpenAPI", | |
| 310 | 309 | "MCP server", | |
| 310 | + | "The g1t CLI", | |
| 311 | 311 | ]; | |
| 312 | 312 | ||
| 313 | 313 | const SECURE: { icon: ReactNode; title: string; about: string; to: string }[] = | |
| ⋯ | |||
| 402 | 402 | </p> | |
| 403 | 403 | <div className="mt-9 flex animate-fade-up flex-wrap items-center justify-center gap-3"> | |
| 404 | 404 | <ButtonLink to="/register" size="lg" className="rounded-full"> | |
| 405 | − | Start for free | |
| 405 | + | Get started | |
| 406 | 406 | <ArrowRight size={15} /> | |
| 407 | 407 | </ButtonLink> | |
| 408 | 408 | <ButtonLink | |
| ⋯ | |||
| 454 | 454 | </h2> | |
| 455 | 455 | <p className="mt-5 max-w-2xl text-lg leading-8 text-muted"> | |
| 456 | 456 | Code, Chat and Artifacts share one set of members, one sign-in and | |
| 457 | − | one bill. The coding agents you connect work as you, through MCP | |
| 458 | − | and the API. | |
| 457 | + | one bill. The coding agents you connect work as you, through MCP, | |
| 458 | + | the g1t CLI and the API. | |
| 459 | 459 | </p> | |
| 460 | 460 | </div> | |
| 461 | 461 | <div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | |
| ⋯ | |||
| 599 | 599 | text: "Pull requests, checks and deploys as cards in the channel", | |
| 600 | 600 | soon: true, | |
| 601 | 601 | }, | |
| 602 | − | { text: "Mentions and approvals in Notifications", soon: true }, | |
| 602 | + | { text: "Chat mentions in Notifications", soon: true }, | |
| 603 | 603 | { | |
| 604 | 604 | text: "A phone app with notifications, Android first, for g1t.sh or your own g1t", | |
| 605 | 605 | soon: true, | |
| ⋯ | |||
| 746 | 746 | <More to={`${DOCS}/guides/merge-queue/`}>The merge queue</More> | |
| 747 | 747 | </div> | |
| 748 | 748 | <div className="rounded-3xl bg-surface p-8 ring-1 ring-line"> | |
| 749 | − | <div className="flex items-center gap-3"> | |
| 750 | − | <Eyebrow>Artifacts</Eyebrow> | |
| 751 | − | <Soon /> | |
| 752 | − | </div> | |
| 749 | + | <Eyebrow>Artifacts</Eyebrow> | |
| 753 | 750 | <h3 className="mt-3 text-2xl font-semibold tracking-tight text-balance"> | |
| 754 | 751 | A knowledge base that keeps itself true | |
| 755 | 752 | </h3> | |
| ⋯ | |||
| 759 | 756 | change touches something a doc cites, the doc is flagged for its | |
| 760 | 757 | owner to update. | |
| 761 | 758 | </p> | |
| 762 | − | <More to={`${DOCS}/guides/artifacts/`}>What Artifacts will do</More> | |
| 759 | + | <More to={`${DOCS}/guides/artifacts/`}>How Artifacts work</More> | |
| 763 | 760 | </div> | |
| 764 | 761 | </section> | |
| 765 | 762 | ||
| ⋯ | |||
| 847 | 844 | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 848 | 845 | Every action is a REST route and an MCP tool, with an OpenAPI | |
| 849 | 846 | document, OAuth apps, access tokens and signed webhooks for every | |
| 850 | − | event. | |
| 847 | + | event. The g1t CLI does the same from a terminal or a script: | |
| 848 | + | open a pull request, wait on its checks, read a failed run's | |
| 849 | + | logs. | |
| 851 | 850 | </p> | |
| 852 | 851 | <div className="mt-6 flex flex-wrap gap-3"> | |
| 853 | 852 | <ButtonLink | |
| ⋯ | |||
| 857 | 856 | Connect an agent | |
| 858 | 857 | <ArrowRight size={14} /> | |
| 859 | 858 | </ButtonLink> | |
| 859 | + | <ButtonLink to={`${DOCS}/guides/cli/`} variant="outline"> | |
| 860 | + | The g1t CLI | |
| 861 | + | </ButtonLink> | |
| 860 | 862 | <ButtonLink to={`${DOCS}/reference/api/`} variant="outline"> | |
| 861 | 863 | API reference | |
| 862 | 864 | </ButtonLink> | |
| ⋯ | |||
| 867 | 869 | Add g1t as a remote MCP server, then sign in through your browser: | |
| 868 | 870 | </p> | |
| 869 | 871 | <CopyLine text="https://mcp.g1t.sh" /> | |
| 872 | + | <p className="pt-3 text-sm text-muted"> | |
| 873 | + | Install the CLI, then sign in with{" "} | |
| 874 | + | <code className="font-mono text-fg-soft">g1t auth login</code>: | |
| 875 | + | </p> | |
| 876 | + | <CopyLine prompt text="curl -fsSL https://g1t.sh/install | sh" /> | |
| 877 | + | <p className="pt-3 text-sm text-muted"> | |
| 878 | + | Underneath, it is plain git and HTTP: | |
| 879 | + | </p> | |
| 870 | 880 | <CopyLine | |
| 871 | 881 | prompt | |
| 882 | + | breakAtSlashes | |
| 872 | 883 | text="git clone https://g1t.sh/flagon-io/g1t.git" | |
| 873 | 884 | /> | |
| 874 | 885 | <CopyLine | |
| 875 | 886 | prompt | |
| 887 | + | breakAtSlashes | |
| 876 | 888 | text="curl https://api.g1t.sh/repos/flagon-io/g1t/queue" | |
| 877 | 889 | /> | |
| 878 | 890 | </div> | |
| ⋯ | |||
| 977 | 989 | Bring your team. Bring your agents. | |
| 978 | 990 | </h2> | |
| 979 | 991 | <p className="mx-auto mt-4 max-w-xl text-fg-soft/80 text-balance"> | |
| 980 | − | Create a workspace in a minute, push a repository and connect the | |
| 981 | − | coding agent you already use. Chat and the forge are free. | |
| 992 | + | Make a workspace, push a repository and connect the coding agent | |
| 993 | + | you already use. Chat and the forge are free. | |
| 982 | 994 | </p> | |
| 983 | 995 | <div className="mt-8 flex flex-wrap justify-center gap-3"> | |
| 984 | 996 | <ButtonLink to="/register" size="lg" className="rounded-full"> | |
| 985 | − | Create your workspace | |
| 997 | + | Get started | |
| 986 | 998 | <ArrowRight size={15} /> | |
| 987 | 999 | </ButtonLink> | |
| 988 | 1000 | <ButtonLink | |
| 740 | 740 | )} | |
| 741 | 741 | > | |
| 742 | 742 | <p className="text-[11px] font-medium text-muted">Preview</p> | |
| 743 | − | <p className="mt-1 truncate font-mono text-[11px] text-accent">pr-431.web.acme.g1t.page</p> | |
| 743 | + | <p className="mt-1 truncate font-mono text-[11px] text-accent">web-git-csv-acme.g1t.page</p> | |
| 744 | 744 | </div> | |
| 745 | 745 | </div> | |
| 746 | 746 | </div> | |
| ⋯ | |||
| 1231 | 1231 | aria-selected={on} | |
| 1232 | 1232 | onClick={() => clock.jump(index)} | |
| 1233 | 1233 | className={cn( | |
| 1234 | − | "group relative overflow-hidden rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-colors", | |
| 1234 | + | "group relative overflow-hidden rounded-full px-2 py-1.5 text-[11px] font-medium whitespace-nowrap ring-1 transition-colors sm:px-3 sm:text-xs", | |
| 1235 | 1235 | on ? "bg-surface text-fg ring-line-strong" : "text-muted ring-line hover:text-fg-soft", | |
| 1236 | 1236 | )} | |
| 1237 | 1237 | > | |
| 1 | − | //! Memory that fills itself. Kept by the work service, beside memory. | |
| 2 | − | //! | |
| 3 | − | //! What agents and people learn arrives as a **candidate**: from what an | |
| 4 | − | //! agent reports learning at the end of a run, a person's correction in a | |
| 5 | − | //! review of an agent's pull request, a merged pull request's decision, or | |
| 6 | − | //! a project's own docs and manifests. A candidate is given to no agent | |
| 7 | − | //! until it is **kept**: by a member in the Review queue, or by g1t when | |
| 8 | − | //! two independent sources say the same thing, or a doc says it with high | |
| 9 | − | //! confidence ([`promotes`]). Nothing that looks like a secret is stored. | |
| 10 | − | //! | |
| 11 | − | //! Each `*Args` struct is the argument of the method of the same name, | |
| 12 | − | //! served at `POST /rpc/<method>`. | |
| 13 | − | ||
| 14 | − | use serde::{Deserialize, Serialize}; | |
| 15 | − | ||
| 16 | − | use crate::agents::{Memory, MemoryKind, MemoryScope, MemoryStatus}; | |
| 17 | − | use crate::repos::RepoPath; | |
| 18 | − | use crate::{User, Viewer}; | |
| 19 | − | ||
| 20 | − | /// The confidence at or above which a doc's word is kept without review. | |
| 21 | − | pub const DOC_CONFIDENCE: f64 = 0.85; | |
| 22 | − | /// How many independent sources keep a candidate without review. | |
| 23 | − | pub const INDEPENDENT_SOURCES: usize = 2; | |
| 24 | − | /// The most items one capture takes. | |
| 25 | − | pub const MAX_CAPTURE: usize = 50; | |
| 26 | − | /// The most an agent reports learning in one run. | |
| 27 | − | pub const MAX_LEARNED: usize = 8; | |
| 28 | − | /// The longest piece of evidence kept, in characters. | |
| 29 | − | pub const MAX_EVIDENCE_CHARS: usize = 400; | |
| 30 | − | ||
| 31 | − | /// Where a captured memory came from. | |
| 32 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 33 | − | #[serde(rename_all = "snake_case")] | |
| 34 | − | pub enum CaptureSource { | |
| 35 | − | /// An agent's run, reporting what it learned. | |
| 36 | − | Run, | |
| 37 | − | /// A person's review of an agent's pull request. | |
| 38 | − | Review, | |
| 39 | − | /// A merged pull request. | |
| 40 | − | Pr, | |
| 41 | − | /// A project's README, AGENTS.md, docs or manifests. | |
| 42 | − | Doc, | |
| 43 | − | /// Written by a person. | |
| 44 | − | Manual, | |
| 45 | − | } | |
| 46 | − | ||
| 47 | − | impl CaptureSource { | |
| 48 | − | pub fn as_str(self) -> &'static str { | |
| 49 | − | match self { | |
| 50 | − | CaptureSource::Run => "run", | |
| 51 | − | CaptureSource::Review => "review", | |
| 52 | − | CaptureSource::Pr => "pr", | |
| 53 | − | CaptureSource::Doc => "doc", | |
| 54 | − | CaptureSource::Manual => "manual", | |
| 55 | − | } | |
| 56 | − | } | |
| 57 | − | } | |
| 58 | − | ||
| 59 | − | /// Whether a candidate is kept without anyone reviewing it: said by | |
| 60 | − | /// `sources` independent sources, or by a doc with `confidence` at or above | |
| 61 | − | /// [`DOC_CONFIDENCE`]. | |
| 62 | − | pub fn promotes(sources: usize, kind: CaptureSource, confidence: Option<f64>) -> bool { | |
| 63 | − | sources >= INDEPENDENT_SOURCES | |
| 64 | − | || (kind == CaptureSource::Doc && confidence.is_some_and(|c| c >= DOC_CONFIDENCE)) | |
| 65 | − | } | |
| 66 | − | ||
| 67 | − | /// A memory's text folded for comparing: lowercase words and digits, one | |
| 68 | − | /// space apart, so wording that differs only in case, punctuation or | |
| 69 | − | /// spacing is the same memory. | |
| 70 | − | pub fn fingerprint(text: &str) -> String { | |
| 71 | − | text.to_lowercase() | |
| 72 | − | .split(|c: char| !c.is_alphanumeric()) | |
| 73 | − | .filter(|word| !word.is_empty()) | |
| 74 | − | .collect::<Vec<_>>() | |
| 75 | − | .join(" ") | |
| 76 | − | } | |
| 77 | − | ||
| 78 | − | /// How alike two memories' words must be (shared over all, as sets) to be | |
| 79 | − | /// one memory. | |
| 80 | − | pub const SAME_WORDS: f64 = 0.85; | |
| 81 | − | /// The fewest distinct words a memory needs before it can be found inside | |
| 82 | − | /// another: shorter ones are too general to be the same thing. | |
| 83 | − | const CONTAINED_MIN_WORDS: usize = 6; | |
| 84 | − | ||
| 85 | − | /// Whether two memories say the same thing: the same words once folded | |
| 86 | − | /// ([`fingerprint`]); one's words, in order, inside the other's; all of a | |
| 87 | − | /// memory's words (at least six of them) among the other's; or most of | |
| 88 | − | /// their words shared (Jaccard at or above [`SAME_WORDS`]). "g1t is a | |
| 89 | − | /// Cargo workspace (apps/*, crates/*)" and the same sentence listing every | |
| 90 | − | /// crate are one memory. | |
| 91 | − | pub fn same_memory(a: &str, b: &str) -> bool { | |
| 92 | − | let (a, b) = (fingerprint(a), fingerprint(b)); | |
| 93 | − | if a.is_empty() || b.is_empty() { | |
| 94 | − | return false; | |
| 95 | − | } | |
| 96 | − | if a == b { | |
| 97 | − | return true; | |
| 98 | − | } | |
| 99 | − | let (short, long) = if a.len() <= b.len() { (&a, &b) } else { (&b, &a) }; | |
| 100 | − | let short_words: std::collections::HashSet<&str> = short.split(' ').collect(); | |
| 101 | − | let long_words: std::collections::HashSet<&str> = long.split(' ').collect(); | |
| 102 | − | // Contiguous: the shorter one's words, in order, inside the longer one's. | |
| 103 | − | if short.split(' ').count() >= 4 && format!(" {long} ").contains(&format!(" {short} ")) { | |
| 104 | − | return true; | |
| 105 | − | } | |
| 106 | − | if short_words.len() >= CONTAINED_MIN_WORDS && short_words.is_subset(&long_words) { | |
| 107 | − | return true; | |
| 108 | − | } | |
| 109 | − | let shared = short_words.intersection(&long_words).count(); | |
| 110 | − | let all = short_words.union(&long_words).count(); | |
| 111 | − | all > 0 && shared as f64 / all as f64 >= SAME_WORDS | |
| 112 | − | } | |
| 113 | − | ||
| 114 | − | /// One thing learned, as a source reports it. | |
| 115 | − | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 116 | − | #[serde(rename_all = "camelCase")] | |
| 117 | − | pub struct CaptureItem { | |
| 118 | − | pub scope: MemoryScope, | |
| 119 | − | /// For a project's memory: its repository's id. | |
| 120 | − | #[serde(default)] | |
| 121 | − | pub repo_id: Option<String>, | |
| 122 | − | #[serde(default)] | |
| 123 | − | pub kind: MemoryKind, | |
| 124 | − | pub text: String, | |
| 125 | − | /// 0 to 1. | |
| 126 | − | #[serde(default)] | |
| 127 | − | pub confidence: Option<f64>, | |
| 128 | − | pub source: CaptureSource, | |
| 129 | − | /// What it came from: `run:<id>`, `comment:<id>`, `pull:<repo id>#<n>`, | |
| 130 | − | /// `doc:<repo id>:<path>`. Two items with the same reference are one | |
| 131 | − | /// source, however often they arrive. | |
| 132 | − | pub reference: String, | |
| 133 | − | /// What it was learned from, quoted. | |
| 134 | − | #[serde(default)] | |
| 135 | − | pub evidence: Option<String>, | |
| 136 | − | /// The pull request it was learned on. | |
| 137 | − | #[serde(default)] | |
| 138 | − | pub number: Option<u32>, | |
| 139 | − | /// The agent run it was learned in. | |
| 140 | − | #[serde(default)] | |
| 141 | − | pub run_id: Option<String>, | |
| 142 | − | } | |
| 143 | − | ||
| 144 | − | /// `capture_memories`: candidates from a service (the context service's | |
| 145 | − | /// backfill and doc reading). Called by services only. Returns `Captured`. | |
| 146 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 147 | − | #[serde(rename_all = "camelCase")] | |
| 148 | − | pub struct CaptureMemoriesArgs { | |
| 149 | − | /// The workspace's slug. | |
| 150 | − | pub workspace: String, | |
| 151 | − | pub items: Vec<CaptureItem>, | |
| 152 | − | /// Who it is recorded as written by, such as `g1t`. | |
| 153 | − | #[serde(default)] | |
| 154 | − | pub by: Option<String>, | |
| 155 | − | } | |
| 156 | − | ||
| 157 | − | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 158 | − | #[serde(rename_all = "camelCase")] | |
| 159 | − | pub struct Captured { | |
| 160 | − | /// New candidates. | |
| 161 | − | pub added: u32, | |
| 162 | − | /// Ones that matched a memory already there, as another sighting. | |
| 163 | − | pub merged: u32, | |
| 164 | − | /// Ones kept by the promotion rule, new or merged. | |
| 165 | − | pub kept: u32, | |
| 166 | − | /// Ones refused: empty, too long, or holding something like a secret. | |
| 167 | − | pub refused: u32, | |
| 168 | − | } | |
| 169 | − | ||
| 170 | − | /// What an agent says it learned, as the harness reports it. | |
| 171 | − | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 172 | − | #[serde(rename_all = "camelCase")] | |
| 173 | − | pub struct LearnedItem { | |
| 174 | − | /// `fact`, `convention`, `decision` or `gotcha`. | |
| 175 | − | #[serde(default)] | |
| 176 | − | pub kind: Option<String>, | |
| 177 | − | /// `project` or `workspace`. | |
| 178 | − | #[serde(default)] | |
| 179 | − | pub scope: Option<String>, | |
| 180 | − | pub text: String, | |
| 181 | − | /// What showed it: a command's output, a file, a failing test. | |
| 182 | − | #[serde(default)] | |
| 183 | − | pub evidence: Option<String>, | |
| 184 | − | } | |
| 185 | − | ||
| 186 | − | /// `report_learned`: a sandbox reporting what its agent learned, with the | |
| 187 | − | /// run's token. Each item arrives as a candidate from the run. Returns | |
| 188 | − | /// `Outcome<Captured>`. | |
| 189 | − | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 190 | − | #[serde(rename_all = "camelCase", default)] | |
| 191 | − | pub struct ReportLearnedArgs { | |
| 192 | − | pub run_id: String, | |
| 193 | − | pub token: String, | |
| 194 | − | pub items: Vec<LearnedItem>, | |
| 195 | − | } | |
| 196 | − | ||
| 197 | − | /// `list_candidates`: memory waiting for review in a workspace and, with | |
| 198 | − | /// `repo`, only that project's. Members only. Returns `Outcome<Vec<Memory>>`. | |
| 199 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 200 | − | pub struct ListCandidatesArgs { | |
| 201 | − | pub viewer: Viewer, | |
| 202 | − | pub workspace: String, | |
| 203 | − | #[serde(default)] | |
| 204 | − | pub repo: Option<RepoPath>, | |
| 205 | − | } | |
| 206 | − | ||
| 207 | − | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 208 | − | #[serde(rename_all = "snake_case")] | |
| 209 | − | pub enum ReviewDecision { | |
| 210 | − | Keep, | |
| 211 | − | Dismiss, | |
| 212 | − | } | |
| 213 | − | ||
| 214 | − | /// `review_memory`: a member keeps a candidate, edited or as it is, or | |
| 215 | − | /// dismisses it. A dismissed memory is not suggested again from the same | |
| 216 | − | /// wording. Returns `Outcome<Memory>`. | |
| 217 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 218 | − | pub struct ReviewMemoryArgs { | |
| 219 | − | pub actor: User, | |
| 220 | − | pub workspace: String, | |
| 221 | − | pub id: String, | |
| 222 | − | pub decision: ReviewDecision, | |
| 223 | − | #[serde(default)] | |
| 224 | − | pub text: Option<String>, | |
| 225 | − | #[serde(default)] | |
| 226 | − | pub kind: Option<MemoryKind>, | |
| 227 | − | } | |
| 228 | − | ||
| 229 | − | /// `memories_by_id`: memories as the context service indexes them, in any | |
| 230 | − | /// status, so a dismissed one can be taken out of search. Services only. | |
| 231 | − | /// Returns `Vec<Memory>`. | |
| 232 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 233 | − | pub struct MemoriesByIdArgs { | |
| 234 | − | pub workspace: String, | |
| 235 | − | pub ids: Vec<String>, | |
| 236 | − | } | |
| 237 | − | ||
| 238 | − | /// `search_memories`: kept memories in a workspace whose text has every | |
| 239 | − | /// word of `query`, pinned first; with `repo_ids`, only the workspace's own | |
| 240 | − | /// and those projects'. Services only: the caller has decided the viewer | |
| 241 | − | /// may read the workspace's memory. Returns `Vec<Memory>`. | |
| 242 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 243 | − | #[serde(rename_all = "camelCase")] | |
| 244 | − | pub struct SearchMemoriesArgs { | |
| 245 | − | pub workspace: String, | |
| 246 | − | #[serde(default)] | |
| 247 | − | pub query: Option<String>, | |
| 248 | − | #[serde(default)] | |
| 249 | − | pub repo_ids: Option<Vec<String>>, | |
| 250 | − | #[serde(default)] | |
| 251 | − | pub status: Option<MemoryStatus>, | |
| 252 | − | #[serde(default)] | |
| 253 | − | pub limit: Option<u32>, | |
| 254 | − | } | |
| 255 | − | ||
| 256 | − | /// `seed_from_pulls`: decision candidates from a repository's last merged | |
| 257 | − | /// pull requests, and convention candidates from people's reviews of its | |
| 258 | − | /// agents' ones. Services only (the backfill). Returns `Captured`. | |
| 259 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 260 | − | #[serde(rename_all = "camelCase")] | |
| 261 | − | pub struct SeedFromPullsArgs { | |
| 262 | − | pub repo_id: String, | |
| 263 | − | /// At most 50; 20 if not given. | |
| 264 | − | #[serde(default)] | |
| 265 | − | pub limit: Option<u32>, | |
| 266 | − | } | |
| 267 | − | ||
| 268 | − | /// `prune_doc_candidates`: after the context service has read every file | |
| 269 | − | /// of a project, removes the project's candidates that came only from its | |
| 270 | − | /// docs and manifests and are still waiting, unless one of `texts` (what | |
| 271 | − | /// they suggest now) is the same memory ([`same_memory`]). Kept and | |
| 272 | − | /// dismissed memory is never touched. Services only. Returns `Pruned`. | |
| 273 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 274 | − | #[serde(rename_all = "camelCase")] | |
| 275 | − | pub struct PruneDocCandidatesArgs { | |
| 276 | − | pub workspace: String, | |
| 277 | − | pub repo_id: String, | |
| 278 | − | #[serde(default)] | |
| 279 | − | pub texts: Vec<String>, | |
| 280 | − | } | |
| 281 | − | ||
| 282 | − | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 283 | − | pub struct Pruned { | |
| 284 | − | /// Candidates removed. | |
| 285 | − | pub removed: u32, | |
| 286 | − | } | |
| 287 | − | ||
| 288 | − | /// Memories, newest first, for listing a review queue. | |
| 289 | − | pub type Candidates = Vec<Memory>; | |
| 290 | − | ||
| 291 | − | /// The `memory.changed` event: a memory was added, changed, reviewed or | |
| 292 | − | /// forgotten. Carries no text; a subscriber asks for the memory by id. Its | |
| 293 | − | /// `repo_id` is the project's for a project's memory, none for the | |
| 294 | − | /// workspace's. | |
| 295 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 296 | − | #[serde(rename_all = "camelCase")] | |
| 297 | − | pub struct MemoryChanged { | |
| 298 | − | pub memory_id: String, | |
| 299 | − | pub workspace: String, | |
| 300 | − | /// `candidate`, `kept` or `dismissed`; `deleted` once forgotten. | |
| 301 | − | pub status: String, | |
| 302 | − | } | |
| 303 | − | ||
| 304 | − | #[cfg(test)] | |
| 305 | − | mod tests { | |
| 306 | − | use super::*; | |
| 307 | − | ||
| 308 | − | #[test] | |
| 309 | − | fn two_independent_sources_keep_a_candidate() { | |
| 310 | − | assert!(!promotes(1, CaptureSource::Run, Some(0.9))); | |
| 311 | − | assert!(promotes(2, CaptureSource::Run, None)); | |
| 312 | − | assert!(promotes(3, CaptureSource::Review, Some(0.1))); | |
| 313 | − | } | |
| 314 | − | ||
| 315 | − | #[test] | |
| 316 | − | fn a_confident_doc_is_kept_and_a_doubtful_one_waits() { | |
| 317 | − | assert!(promotes(1, CaptureSource::Doc, Some(0.9))); | |
| 318 | − | assert!(promotes(1, CaptureSource::Doc, Some(DOC_CONFIDENCE))); | |
| 319 | − | assert!(!promotes(1, CaptureSource::Doc, Some(0.6))); | |
| 320 | − | assert!(!promotes(1, CaptureSource::Doc, None)); | |
| 321 | − | // Only a doc's confidence counts on its own. | |
| 322 | − | assert!(!promotes(1, CaptureSource::Pr, Some(1.0))); | |
| 323 | − | } | |
| 324 | − | ||
| 325 | − | #[test] | |
| 326 | − | fn fingerprints_ignore_case_punctuation_and_spacing() { | |
| 327 | − | assert_eq!(fingerprint("Use pnpm, never npm!"), "use pnpm never npm"); | |
| 328 | − | assert_eq!(fingerprint("use pnpm never NPM"), fingerprint("Use pnpm; never npm.")); | |
| 329 | − | assert_ne!(fingerprint("use pnpm"), fingerprint("use npm")); | |
| 330 | − | } | |
| 331 | − | ||
| 332 | − | #[test] | |
| 333 | − | fn near_duplicates_are_one_memory() { | |
| 334 | − | assert!(same_memory("Use pnpm, never npm.", "use pnpm never NPM")); | |
| 335 | − | // A crate list that grew, and the same fact without the list. | |
| 336 | − | let before = "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing); `cargo test` runs its tests."; | |
| 337 | − | let after = "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing, services/work); `cargo test` runs its tests."; | |
| 338 | − | assert!(same_memory(before, after)); | |
| 339 | − | assert!(same_memory(before, "g1t is a Cargo workspace (apps/*, crates/*, services/*); `cargo test` runs its tests.")); | |
| 340 | − | // One cut short, inside the whole of it. | |
| 341 | − | assert!(same_memory( | |
| 342 | − | "Roles. Viewer, commenter, planner and approver map onto", | |
| 343 | − | "Roles. Viewer, commenter, planner and approver map onto the five repository roles." | |
| 344 | − | )); | |
| 345 | − | // Different facts that share words are not. | |
| 346 | − | assert!(!same_memory("Use pnpm to install.", "Use npm to install.")); | |
| 347 | − | assert!(!same_memory("Run cargo test in the crate you changed.", "Run npm test in the app you changed.")); | |
| 348 | − | assert!(!same_memory("use pnpm", "use pnpm in the web app and npm in the docs, which predates it")); | |
| 349 | − | assert!(!same_memory("", "anything")); | |
| 350 | − | } | |
| 351 | − | } |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import { | |
| 5 | − | ABILITY_LEVELS, | |
| 6 | − | COMPUTER_ABILITIES, | |
| 7 | − | G1T_ABILITIES, | |
| 8 | − | abilitiesSummary, | |
| 9 | − | allAbilities, | |
| 10 | − | askedFor, | |
| 11 | − | autonomyOfLevel, | |
| 12 | − | checkMcpName, | |
| 13 | − | checkMcpUrl, | |
| 14 | − | connectorsWithAbilities, | |
| 15 | − | defaultLevel, | |
| 16 | − | findAbility, | |
| 17 | − | integrationAbilities, | |
| 18 | − | levelOfAutonomy, | |
| 19 | − | maxLevel, | |
| 20 | − | mcpAbility, | |
| 21 | − | mcpToolName, | |
| 22 | − | resolveAbilities, | |
| 23 | − | withSetting, | |
| 24 | − | withinLevel, | |
| 25 | − | } from "./abilities.ts"; | |
| 26 | − | import { CONNECTORS } from "./connectors.ts"; | |
| 27 | − | ||
| 28 | − | const AUTONOMY = { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" } as const; | |
| 29 | − | const resolve = (over: Partial<Parameters<typeof resolveAbilities>[0]> = {}) => resolveAbilities({ connectors: CONNECTORS, abilities: null, autonomy: AUTONOMY, connected: [], ...over }); | |
| 30 | − | ||
| 31 | − | test("defaults by kind: reads alone, writes inside g1t when asked for, sending outside asks first, restricted never and no freer than asking", () => { | |
| 32 | − | assert.equal(defaultLevel("read"), "alone"); | |
| 33 | − | assert.equal(defaultLevel("write"), "asked"); | |
| 34 | − | assert.equal(defaultLevel("send"), "ask"); | |
| 35 | − | assert.equal(defaultLevel("restricted"), "never"); | |
| 36 | − | assert.equal(maxLevel("restricted"), "ask"); | |
| 37 | − | assert.equal(maxLevel("send"), "alone"); | |
| 38 | − | assert.ok(withinLevel("never", "ask")); | |
| 39 | − | assert.ok(!withinLevel("alone", "ask")); | |
| 40 | − | assert.deepEqual(ABILITY_LEVELS, ["alone", "asked", "ask", "never"]); | |
| 41 | − | }); | |
| 42 | − | ||
| 43 | − | test("every integration ability belongs to an available workspace connector with a provider, and its kind fits the connector's capabilities", () => { | |
| 44 | − | const withAbilities = connectorsWithAbilities(CONNECTORS); | |
| 45 | − | assert.ok(withAbilities.length >= 3, "Linear, Jira and Sentry at least"); | |
| 46 | − | for (const connector of withAbilities) { | |
| 47 | − | assert.equal(connector.status, "available", `${connector.id} is connectable today`); | |
| 48 | − | assert.ok(connector.scopes.includes("workspace"), `${connector.id} connects for a workspace`); | |
| 49 | − | assert.ok(connector.provider, `${connector.id} has a provider the integrations service knows`); | |
| 50 | − | const defs = integrationAbilities(connector.id); | |
| 51 | − | const caps = connector.capabilities ?? []; | |
| 52 | − | if (defs.some((d) => d.kind === "read")) assert.ok(caps.includes("Agents can read"), `${connector.id} says agents can read`); | |
| 53 | − | if (defs.some((d) => d.kind === "send")) assert.ok(caps.includes("Writes back"), `${connector.id} says it writes back`); | |
| 54 | − | for (const def of defs) { | |
| 55 | − | assert.equal(def.id, `integration:${connector.id}:${def.id.split(":")[2]}`); | |
| 56 | − | assert.equal(def.tools.length, 1, "one tool per integration ability"); | |
| 57 | − | } | |
| 58 | − | } | |
| 59 | − | assert.deepEqual(integrationAbilities("datadog"), [], "Datadog opens issues by itself: nothing for an agent to call"); | |
| 60 | − | }); | |
| 61 | − | ||
| 62 | − | test("g1t's own are always on and the four autonomy ones keep their choices as levels", () => { | |
| 63 | − | const sections = resolve(); | |
| 64 | − | const g1t = sections.find((s) => s.group === "g1t")!; | |
| 65 | − | const artifacts = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:artifacts")!; | |
| 66 | − | assert.equal(artifacts.level, "alone"); | |
| 67 | − | assert.equal(artifacts.can_change, false); | |
| 68 | − | const merge = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:merge")!; | |
| 69 | − | assert.equal(merge.level, "ask", "approval reads as Ask first"); | |
| 70 | − | assert.deepEqual(merge.choices, ["alone", "ask", "never"]); | |
| 71 | − | const deploy = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:deploy")!; | |
| 72 | − | assert.equal(deploy.kind, "restricted"); | |
| 73 | − | assert.ok(!deploy.choices.includes("alone"), "production deploys never go alone"); | |
| 74 | − | assert.equal(levelOfAutonomy("suggest"), "ask"); | |
| 75 | − | assert.equal(autonomyOfLevel("edit_docs", "ask"), "suggest"); | |
| 76 | − | assert.equal(autonomyOfLevel("merge", "ask"), "approval"); | |
| 77 | − | assert.equal(autonomyOfLevel("merge", "never"), "never"); | |
| 78 | − | assert.equal(G1T_ABILITIES.filter((d) => d.autonomy).length, 4); | |
| 79 | − | const computer = sections.find((s) => s.group === "computer")!; | |
| 80 | − | const shell = computer.sources[0]!.abilities.find((a) => a.id === "computer:shell")!; | |
| 81 | − | assert.equal(shell.status, "ready"); | |
| 82 | − | assert.equal(shell.level, "asked", "commands run when asked for, until the person says otherwise"); | |
| 83 | − | assert.deepEqual(shell.choices, ["alone", "asked", "ask", "never"]); | |
| 84 | − | const files = computer.sources[0]!.abilities.find((a) => a.id === "computer:files")!; | |
| 85 | − | assert.equal(files.level, "alone"); | |
| 86 | − | assert.ok(files.can_change); | |
| 87 | − | const coming = computer.sources[0]!.abilities.filter((a) => a.id === "computer:browser" || a.id === "computer:web"); | |
| 88 | − | assert.equal(coming.length, 2); | |
| 89 | − | assert.ok(coming.every((a) => a.status === "coming" && a.choices.length === 0 && !a.can_change), "the browser and web search are still coming"); | |
| 90 | − | assert.equal(COMPUTER_ABILITIES.length, 4); | |
| 91 | − | }); | |
| 92 | − | ||
| 93 | − | test("a connected integration lists its rows at their defaults; one that isn't is listed, but nothing there can be changed", () => { | |
| 94 | − | const sections = resolve({ connected: ["linear"] }); | |
| 95 | − | const integrations = sections.find((s) => s.group === "integration")!; | |
| 96 | − | const linear = integrations.sources.find((s) => s.id === "linear")!; | |
| 97 | − | assert.equal(linear.connected, true); | |
| 98 | − | assert.deepEqual( | |
| 99 | − | linear.abilities.map((a) => [a.id, a.level, a.credentials, a.can_change]), | |
| 100 | − | [ | |
| 101 | − | ["integration:linear:read", "alone", "workspace", true], | |
| 102 | − | ["integration:linear:import", "asked", "workspace", true], | |
| 103 | − | ["integration:linear:comment", "ask", "workspace", true], | |
| 104 | − | ], | |
| 105 | − | ); | |
| 106 | − | assert.equal(linear.abilities[0]!.personal_available, false, "Linear's personal side is still coming"); | |
| 107 | − | const jira = integrations.sources.find((s) => s.id === "jira")!; | |
| 108 | − | assert.equal(jira.connected, false); | |
| 109 | − | assert.ok(jira.abilities.every((a) => !a.can_change)); | |
| 110 | − | assert.equal(integrations.sources[0]!.id, "linear", "connected first"); | |
| 111 | − | assert.ok(!integrations.sources.some((s) => s.id === "webhooks" || s.id === "anthropic"), "nothing to call, not connected: not listed"); | |
| 112 | − | const withDatadog = resolve({ connected: ["datadog"] }).find((s) => s.group === "integration")!; | |
| 113 | − | const datadog = withDatadog.sources.find((s) => s.id === "datadog")!; | |
| 114 | − | assert.equal(datadog.abilities.length, 0); | |
| 115 | − | assert.match(datadog.note ?? "", /Opens issues by itself/); | |
| 116 | − | }); | |
| 117 | − | ||
| 118 | − | test("a setting moves a level within its kind's limit, and is dropped when it is the default again", () => { | |
| 119 | − | let abilities = withSetting(null, "integration:linear:comment", { level: "alone" }); | |
| 120 | − | assert.deepEqual(abilities.settings, { "integration:linear:comment": { level: "alone" } }); | |
| 121 | − | const sections = resolve({ connected: ["linear"], abilities }); | |
| 122 | − | assert.equal(findAbility(sections, "integration:linear:comment")!.ability.level, "alone"); | |
| 123 | − | abilities = withSetting(abilities, "integration:linear:comment", { credentials: "asker" }); | |
| 124 | − | assert.deepEqual(abilities.settings["integration:linear:comment"], { level: "alone", credentials: "asker" }); | |
| 125 | − | abilities = withSetting(abilities, "integration:linear:comment", { level: null, credentials: null }); | |
| 126 | − | assert.deepEqual(abilities.settings, {}, "nothing kept once everything is the default"); | |
| 127 | − | // A restricted ability set freer than Ask is held at Ask. | |
| 128 | − | const held = resolveAbilities({ connectors: CONNECTORS, abilities: null, autonomy: { ...AUTONOMY, deploy_production: "approval" }, connected: [] }); | |
| 129 | − | assert.equal(findAbility(held, "g1t:deploy")!.ability.max, "ask"); | |
| 130 | − | }); | |
| 131 | − | ||
| 132 | − | test("a personal agent runs on the asker's connections unless an owner chose the workspace's", () => { | |
| 133 | − | const sections = resolve({ connected: ["linear"], personal: true }); | |
| 134 | − | assert.equal(findAbility(sections, "integration:linear:read")!.ability.credentials, "asker"); | |
| 135 | − | const chosen = resolve({ connected: ["linear"], personal: true, abilities: withSetting(null, "integration:linear:read", { credentials: "workspace" }) }); | |
| 136 | − | assert.equal(findAbility(chosen, "integration:linear:read")!.ability.credentials, "workspace"); | |
| 137 | − | }); | |
| 138 | − | ||
| 139 | − | test("MCP servers: each listed tool is a row, a write unless the server says it reads, offered under <server>__<tool>", () => { | |
| 140 | − | const server = { | |
| 141 | − | id: "mcp_1", | |
| 142 | − | name: "weather", | |
| 143 | − | url: "https://mcp.example.com/", | |
| 144 | − | tools: [ | |
| 145 | − | { name: "get_forecast", description: "Today's forecast", kind: "read" as const, input_schema: { type: "object", properties: {} } }, | |
| 146 | − | { name: "set_alert", description: "", kind: "write" as const, input_schema: { type: "object", properties: {} } }, | |
| 147 | − | ], | |
| 148 | − | added_by: "ana", | |
| 149 | − | added_at: "2026-10-10T00:00:00.000Z", | |
| 150 | − | checked_at: null, | |
| 151 | − | problem: null, | |
| 152 | − | }; | |
| 153 | − | const sections = resolve({ abilities: { settings: {}, mcp_servers: [server] } }); | |
| 154 | − | const mcp = sections.find((s) => s.group === "mcp")!; | |
| 155 | − | assert.equal(mcp.sources.length, 1); | |
| 156 | − | const [read, write] = mcp.sources[0]!.abilities; | |
| 157 | − | assert.equal(read!.level, "alone"); | |
| 158 | − | assert.equal(write!.level, "ask", "a write outside g1t asks first"); | |
| 159 | − | assert.equal(write!.kind, "send"); | |
| 160 | − | assert.deepEqual(read!.tools, ["weather__get_forecast"]); | |
| 161 | − | assert.equal(mcpToolName("My Server", "do.thing"), "my_server__do_thing"); | |
| 162 | − | assert.equal(mcpAbility(server, server.tools[1]!).id, "mcp:mcp_1:set_alert"); | |
| 163 | − | }); | |
| 164 | − | ||
| 165 | − | test("an MCP server's address is HTTPS on a public host, never local, an address or g1t's own", () => { | |
| 166 | − | assert.equal(checkMcpUrl("https://mcp.example.com/sse").ok, true); | |
| 167 | − | assert.equal(checkMcpUrl("http://mcp.example.com/").ok, false); | |
| 168 | − | assert.equal(checkMcpUrl("https://localhost:3000/").ok, false); | |
| 169 | − | assert.equal(checkMcpUrl("https://10.0.0.5/").ok, false); | |
| 170 | − | assert.equal(checkMcpUrl("https://[::1]/").ok, false); | |
| 171 | − | assert.equal(checkMcpUrl("https://mcp.internal/").ok, false); | |
| 172 | − | assert.equal(checkMcpUrl("https://api.g1t.sh/mcp").ok, false); | |
| 173 | − | assert.equal(checkMcpUrl("https://user:pw@mcp.example.com/").ok, false); | |
| 174 | − | assert.equal(checkMcpUrl("not a url").ok, false); | |
| 175 | − | assert.equal(checkMcpName("Weather").ok, true); | |
| 176 | − | assert.equal(checkMcpName("a").ok, false); | |
| 177 | − | assert.equal(checkMcpName("bad name").ok, false); | |
| 178 | − | }); | |
| 179 | − | ||
| 180 | − | test("alone when asked for it: the item's key or the ability's name in what the person said", () => { | |
| 181 | − | assert.ok(askedFor("Can you comment on ENG-42 with the test plan?", ["ENG-42", "comment"])); | |
| 182 | − | assert.ok(askedFor("what's eng-42 about", ["ENG-42"]), "any case"); | |
| 183 | − | assert.ok(!askedFor("Summarise the thread", ["ENG-42", "import"])); | |
| 184 | − | assert.ok(!askedFor("", ["ENG-42"])); | |
| 185 | − | }); | |
| 186 | − | ||
| 187 | − | test("the summary says what it does alone, when asked, after asking, and never", () => { | |
| 188 | − | const abilities = withSetting(withSetting(null, "integration:linear:comment", { level: "never" }), "integration:sentry:resolve", { level: "alone" }); | |
| 189 | − | const sections = resolve({ connected: ["linear", "sentry"], abilities }); | |
| 190 | − | const summary = abilitiesSummary(sections); | |
| 191 | − | assert.equal( | |
| 192 | − | summary, | |
| 193 | − | "Can open pull requests, change files on its computer, read issues in Linear, read issues in Sentry and resolve issues in Sentry on its own; runs commands on its computer, imports issues in Linear and imports issues in Sentry when asked for it; asks before merging, deploying to production, editing docs and commenting in Sentry; never comments in Linear.", | |
| 194 | − | ); | |
| 195 | − | assert.equal(allAbilities(sections).filter((a) => a.group === "integration" && a.can_change).length, 7); | |
| 196 | − | assert.match(abilitiesSummary(resolve()), /open pull requests and change files on its computer on its own/); | |
| 197 | − | }); |
| 1 | − | /** | |
| 2 | − | * An agent's abilities (docs.g1t.sh/guides/agent-abilities/): what it can | |
| 3 | − | * do, in groups, and for each whether it does it on its own, only when the | |
| 4 | − | * person asked for it, after asking first, or never. | |
| 5 | − | * | |
| 6 | − | * - **g1t's own** (artifacts, chat, code, issues and pull requests, files, | |
| 7 | − | * colleagues) are always on, within the access of the person it acts | |
| 8 | − | * for. The four that write code and docs keep the agent's `autonomy` | |
| 9 | − | * choices, shown here as levels. | |
| 10 | − | * - **Its computer**: a shell and files on a computer of its own, which | |
| 11 | − | * it uses in sessions (docs.g1t.sh/guides/agents/, "Its computer"); a | |
| 12 | − | * browser and web search are coming. | |
| 13 | − | * - **Integrations**: every connector the workspace has connected lists | |
| 14 | − | * what an agent can do through it, one row per action, from the | |
| 15 | − | * connector catalog (./connectors.ts) and what the integrations service | |
| 16 | − | * does today. Each row says whose connection it runs on: the | |
| 17 | − | * workspace's, or the asking person's own. | |
| 18 | − | * - **MCP servers** an owner adds: each tool the server lists is a row. A | |
| 19 | − | * tool that doesn't say it only reads is treated as a write. | |
| 20 | − | * | |
| 21 | − | * Levels default by what a thing does: reading is alone; writing inside | |
| 22 | − | * g1t is alone when the person asked for it; sending outside g1t asks | |
| 23 | − | * first; purchases, credentials and permission changes are never, and | |
| 24 | − | * can't be raised above asking. | |
| 25 | − | * | |
| 26 | − | * The agents service enforces these in code when it offers tools and when | |
| 27 | − | * one is called; the Abilities tab and the agents service both resolve an | |
| 28 | − | * agent's choices with `resolveAbilities`, handing it the connector catalog | |
| 29 | − | * (./connectors.ts `CONNECTORS`): no value imports here, so services test | |
| 30 | − | * it under Node as it is. Wire shapes are snake_case. | |
| 31 | − | */ | |
| 32 | − | import type { Connector } from "./connectors"; | |
| 33 | − | import type { AgentAutonomy } from "./workspace-agents"; | |
| 34 | − | ||
| 35 | − | /** Alone; alone when the asker asked for it; ask first; never. */ | |
| 36 | − | export type AbilityLevel = "alone" | "asked" | "ask" | "never"; | |
| 37 | − | ||
| 38 | − | export const ABILITY_LEVELS: readonly AbilityLevel[] = ["alone", "asked", "ask", "never"]; | |
| 39 | − | ||
| 40 | − | export const ABILITY_LEVEL_LABELS: Record<AbilityLevel, string> = { | |
| 41 | − | alone: "Alone", | |
| 42 | − | asked: "Alone when asked for it", | |
| 43 | − | ask: "Ask first", | |
| 44 | − | never: "Never", | |
| 45 | − | }; | |
| 46 | − | ||
| 47 | − | /** How much a level lets through: a lower number is freer. */ | |
| 48 | − | const ORDER: Record<AbilityLevel, number> = { alone: 0, asked: 1, ask: 2, never: 3 }; | |
| 49 | − | ||
| 50 | − | export type AbilityGroup = "g1t" | "computer" | "integration" | "mcp"; | |
| 51 | − | ||
| 52 | − | /** | |
| 53 | − | * What an ability does: `read`, `write` (inside g1t), `send` (something | |
| 54 | − | * leaves g1t: a comment, a message, a change in another system) or | |
| 55 | − | * `restricted` (purchases, credentials, permission changes). | |
| 56 | − | */ | |
| 57 | − | export type AbilityKind = "read" | "write" | "send" | "restricted"; | |
| 58 | − | ||
| 59 | − | /** Whose connection an integration ability runs on. */ | |
| 60 | − | export type AbilityCredentials = "workspace" | "asker"; | |
| 61 | − | ||
| 62 | − | export type AbilityStatus = "ready" | "coming"; | |
| 63 | − | ||
| 64 | − | /** The level a kind starts at. */ | |
| 65 | − | export function defaultLevel(kind: AbilityKind): AbilityLevel { | |
| 66 | − | switch (kind) { | |
| 67 | − | case "read": | |
| 68 | − | return "alone"; | |
| 69 | − | case "write": | |
| 70 | − | return "asked"; | |
| 71 | − | case "send": | |
| 72 | − | return "ask"; | |
| 73 | − | case "restricted": | |
| 74 | − | return "never"; | |
| 75 | − | } | |
| 76 | − | } | |
| 77 | − | ||
| 78 | − | /** The freest level a kind may be set to: restricted things never go above asking. */ | |
| 79 | − | export function maxLevel(kind: AbilityKind): AbilityLevel { | |
| 80 | − | return kind === "restricted" ? "ask" : "alone"; | |
| 81 | − | } | |
| 82 | − | ||
| 83 | − | /** Whether `level` is within `max`: no freer than it. */ | |
| 84 | − | export function withinLevel(level: AbilityLevel, max: AbilityLevel): boolean { | |
| 85 | − | return ORDER[level] >= ORDER[max]; | |
| 86 | − | } | |
| 87 | − | ||
| 88 | − | /** One ability as the catalog defines it. */ | |
| 89 | − | export type AbilityDef = { | |
| 90 | − | /** `g1t:artifacts`, `integration:linear:read`, `mcp:<server>:<tool>`, `computer:shell`. */ | |
| 91 | − | id: string; | |
| 92 | − | group: AbilityGroup; | |
| 93 | − | label: string; | |
| 94 | − | /** What it does, in a line. */ | |
| 95 | − | about: string; | |
| 96 | − | kind: AbilityKind; | |
| 97 | − | /** The agent tools it offers (the agents service's names), for the row's chips. */ | |
| 98 | − | tools: string[]; | |
| 99 | − | status: AbilityStatus; | |
| 100 | − | /** | |
| 101 | − | * The agent's `autonomy` field it reads and writes, for g1t's own code | |
| 102 | − | * and docs abilities, with the levels that field allows. | |
| 103 | − | */ | |
| 104 | − | autonomy?: { key: keyof AgentAutonomy; levels: AbilityLevel[] } | null; | |
| 105 | − | /** The level it starts at, when that is not what its kind would say. */ | |
| 106 | − | default?: AbilityLevel | null; | |
| 107 | − | }; | |
| 108 | − | ||
| 109 | − | /** What an agent keeps for one ability. Absent fields mean the default. */ | |
| 110 | − | export type AbilitySetting = { level?: AbilityLevel | null; credentials?: AbilityCredentials | null }; | |
| 111 | − | ||
| 112 | − | /** A tool an MCP server lists, as it was found. */ | |
| 113 | − | export type McpTool = { | |
| 114 | − | name: string; | |
| 115 | − | description: string; | |
| 116 | − | /** `read` when the server says the tool only reads; `write` otherwise, which is also what an unknown tool is. */ | |
| 117 | − | kind: "read" | "write"; | |
| 118 | − | /** Its arguments, as the server describes them (JSON Schema); what the agent is offered. */ | |
| 119 | − | input_schema: Record<string, unknown>; | |
| 120 | − | }; | |
| 121 | − | ||
| 122 | − | /** An MCP server an owner added to one agent. */ | |
| 123 | − | export type McpServer = { | |
| 124 | − | /** `mcp_…`, stable: ability ids are made from it. */ | |
| 125 | − | id: string; | |
| 126 | − | /** Lowercase letters, digits and hyphens: the tools are offered as `<name>__<tool>`. */ | |
| 127 | − | name: string; | |
| 128 | − | /** Its HTTPS address; the host is checked when it is added. */ | |
| 129 | − | url: string; | |
| 130 | − | tools: McpTool[]; | |
| 131 | − | added_by: string; | |
| 132 | − | /** RFC 3339. */ | |
| 133 | − | added_at: string; | |
| 134 | − | /** When its tools were last listed, and what went wrong if they couldn't be. */ | |
| 135 | − | checked_at: string | null; | |
| 136 | − | problem: string | null; | |
| 137 | − | }; | |
| 138 | − | ||
| 139 | − | /** What an agent's definition keeps about its abilities. */ | |
| 140 | − | export type AgentAbilities = { | |
| 141 | − | /** By ability id: only what differs from the default is kept. */ | |
| 142 | − | settings: Record<string, AbilitySetting>; | |
| 143 | − | mcp_servers: McpServer[]; | |
| 144 | − | }; | |
| 145 | − | ||
| 146 | − | export const EMPTY_ABILITIES: AgentAbilities = { settings: {}, mcp_servers: [] }; | |
| 147 | − | ||
| 148 | − | /** What a change to an agent sends for its abilities: the settings, whole. MCP servers have their own calls. */ | |
| 149 | − | export type AgentAbilitiesChange = { settings: Record<string, AbilitySetting> }; | |
| 150 | − | ||
| 151 | − | /** The most MCP servers one agent has, and the most tools one server lists. */ | |
| 152 | − | export const MAX_MCP_SERVERS = 10; | |
| 153 | − | export const MAX_MCP_TOOLS = 40; | |
| 154 | − | ||
| 155 | − | /** An ability as one agent has it now. */ | |
| 156 | − | export type Ability = AbilityDef & { | |
| 157 | − | level: AbilityLevel; | |
| 158 | − | /** The freest level it may be set to. */ | |
| 159 | − | max: AbilityLevel; | |
| 160 | − | /** The levels it may be set to, freest first. */ | |
| 161 | − | choices: AbilityLevel[]; | |
| 162 | − | /** Whether the level can be changed at all: g1t's own reads are always on. */ | |
| 163 | − | can_change: boolean; | |
| 164 | − | /** For an integration: whose connection it runs on. Null elsewhere. */ | |
| 165 | − | credentials: AbilityCredentials | null; | |
| 166 | − | /** Whether the asking person could connect it themselves (the connector has a personal side that is available). */ | |
| 167 | − | personal_available: boolean; | |
| 168 | − | }; | |
| 169 | − | ||
| 170 | − | /** One source of abilities: a connector, an MCP server, or g1t itself. */ | |
| 171 | − | export type AbilitySource = { | |
| 172 | − | id: string; | |
| 173 | − | name: string; | |
| 174 | − | /** For an integration or an MCP server: whether the workspace has it. */ | |
| 175 | − | connected: boolean; | |
| 176 | − | /** Where it is connected or managed; null when there is nowhere. */ | |
| 177 | − | href: string | null; | |
| 178 | − | /** A line about it when it has no abilities, or something is wrong with it. */ | |
| 179 | − | note: string | null; | |
| 180 | − | abilities: Ability[]; | |
| 181 | − | }; | |
| 182 | − | ||
| 183 | − | export type AbilitySection = { | |
| 184 | − | group: AbilityGroup; | |
| 185 | − | title: string; | |
| 186 | − | about: string; | |
| 187 | − | sources: AbilitySource[]; | |
| 188 | − | }; | |
| 189 | − | ||
| 190 | − | /** What a level means for an agent, in its own words. */ | |
| 191 | − | export function levelWords(level: AbilityLevel): string { | |
| 192 | − | switch (level) { | |
| 193 | − | case "alone": | |
| 194 | − | return "on its own"; | |
| 195 | − | case "asked": | |
| 196 | − | return "only when the person asked for that"; | |
| 197 | − | case "ask": | |
| 198 | − | return "after asking first"; | |
| 199 | − | case "never": | |
| 200 | − | return "never"; | |
| 201 | − | } | |
| 202 | − | } | |
| 203 | − | ||
| 204 | − | // g1t's own --------------------------------------------------------------- | |
| 205 | − | ||
| 206 | − | /** g1t's own abilities: always on, within the asker's access; the four autonomy ones keep their choices. */ | |
| 207 | − | export const G1T_ABILITIES: AbilityDef[] = [ | |
| 208 | − | { | |
| 209 | − | id: "g1t:artifacts", | |
| 210 | − | group: "g1t", | |
| 211 | − | label: "Artifacts", | |
| 212 | − | about: "Search, read, write, edit and share the workspace's artifacts, where the person it acts for can.", | |
| 213 | − | kind: "read", | |
| 214 | − | tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact"], | |
| 215 | − | status: "ready", | |
| 216 | − | }, | |
| 217 | − | { | |
| 218 | − | id: "g1t:chat", | |
| 219 | − | group: "g1t", | |
| 220 | − | label: "Chat", | |
| 221 | − | about: "Search messages and read threads everyone in the conversation can read, and the roster.", | |
| 222 | − | kind: "read", | |
| 223 | − | tools: ["search_messages", "read_thread", "workspace_roster"], | |
| 224 | − | status: "ready", | |
| 225 | − | }, | |
| 226 | − | { | |
| 227 | − | id: "g1t:code", | |
| 228 | − | group: "g1t", | |
| 229 | − | label: "Code", | |
| 230 | − | about: "Read files and search code in repositories everyone in the conversation can read.", | |
| 231 | − | kind: "read", | |
| 232 | − | tools: ["list_repositories", "search_code", "read_file"], | |
| 233 | − | status: "ready", | |
| 234 | − | }, | |
| 235 | − | { | |
| 236 | − | id: "g1t:issues", | |
| 237 | − | group: "g1t", | |
| 238 | − | label: "Issues and pull requests", | |
| 239 | − | about: "Read issues and pull requests, draft issues as cards, and comment or review on behalf of the person who asked.", | |
| 240 | − | kind: "read", | |
| 241 | − | tools: ["list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"], | |
| 242 | − | status: "ready", | |
| 243 | − | }, | |
| 244 | − | { | |
| 245 | − | id: "g1t:pulls", | |
| 246 | − | group: "g1t", | |
| 247 | − | label: "Open pull requests", | |
| 248 | − | about: "Open pull requests from its sessions. Rules, protected branches and required checks still apply.", | |
| 249 | − | kind: "write", | |
| 250 | − | tools: [], | |
| 251 | − | status: "ready", | |
| 252 | − | autonomy: { key: "open_pull_requests", levels: ["alone", "ask"] }, | |
| 253 | − | }, | |
| 254 | − | { | |
| 255 | − | id: "g1t:merge", | |
| 256 | − | group: "g1t", | |
| 257 | − | label: "Merge", | |
| 258 | − | about: "Merge pull requests that have what the branch requires.", | |
| 259 | − | kind: "write", | |
| 260 | − | tools: [], | |
| 261 | − | status: "ready", | |
| 262 | − | autonomy: { key: "merge", levels: ["alone", "ask", "never"] }, | |
| 263 | − | }, | |
| 264 | − | { | |
| 265 | − | id: "g1t:deploy", | |
| 266 | − | group: "g1t", | |
| 267 | − | label: "Deploy to production", | |
| 268 | − | about: "Deploy a project's production environment.", | |
| 269 | − | kind: "restricted", | |
| 270 | − | tools: [], | |
| 271 | − | status: "ready", | |
| 272 | − | autonomy: { key: "deploy_production", levels: ["ask", "never"] }, | |
| 273 | − | }, | |
| 274 | − | { | |
| 275 | − | id: "g1t:docs", | |
| 276 | − | group: "g1t", | |
| 277 | − | label: "Edit docs", | |
| 278 | − | about: "Change docs directly, or leave each change as a suggestion someone accepts.", | |
| 279 | − | kind: "write", | |
| 280 | − | tools: ["edit_artifact"], | |
| 281 | − | status: "ready", | |
| 282 | − | autonomy: { key: "edit_docs", levels: ["alone", "ask"] }, | |
| 283 | − | }, | |
| 284 | − | { | |
| 285 | − | id: "g1t:files", | |
| 286 | − | group: "g1t", | |
| 287 | − | label: "Make files", | |
| 288 | − | about: "Make PDFs, Word documents, spreadsheets, CSVs and Markdown files, kept with a doc the person can open.", | |
| 289 | − | kind: "write", | |
| 290 | − | tools: ["make_file"], | |
| 291 | − | status: "ready", | |
| 292 | − | }, | |
| 293 | − | { | |
| 294 | − | id: "g1t:colleagues", | |
| 295 | − | group: "g1t", | |
| 296 | − | label: "Colleagues and memory", | |
| 297 | − | about: "Ask a colleague, hand work off, bring one into a session, use its subagents, start sessions, and remember facts.", | |
| 298 | − | kind: "write", | |
| 299 | − | tools: ["ask_colleague", "hand_off", "bring_in", "use_subagent", "start_session", "post_update", "remember", "forget", "use_skill"], | |
| 300 | − | status: "ready", | |
| 301 | − | }, | |
| 302 | − | ]; | |
| 303 | − | ||
| 304 | − | /** `autonomy` values as levels, and back. */ | |
| 305 | − | export function levelOfAutonomy(value: string): AbilityLevel { | |
| 306 | − | switch (value) { | |
| 307 | − | case "alone": | |
| 308 | − | return "alone"; | |
| 309 | − | case "never": | |
| 310 | − | return "never"; | |
| 311 | − | default: | |
| 312 | − | // `approval` and `suggest` both mean a person acts first. | |
| 313 | − | return "ask"; | |
| 314 | − | } | |
| 315 | − | } | |
| 316 | − | ||
| 317 | − | export function autonomyOfLevel(key: keyof AgentAutonomy, level: AbilityLevel): string { | |
| 318 | − | if (level === "alone") return "alone"; | |
| 319 | − | if (level === "never") return "never"; | |
| 320 | − | return key === "edit_docs" ? "suggest" : "approval"; | |
| 321 | − | } | |
| 322 | − | ||
| 323 | − | // Its computer ----------------------------------------------------------- | |
| 324 | − | ||
| 325 | − | /** | |
| 326 | − | * The agent's own computer: a persistent home on g1t cloud that its | |
| 327 | − | * sessions use and that sleeps when idle. Running commands is a write it | |
| 328 | − | * does alone when asked for it; reading and writing its own files is alone, | |
| 329 | − | * since nothing leaves the computer. Chat replies never use it. | |
| 330 | − | */ | |
| 331 | − | export const COMPUTER_ABILITIES: AbilityDef[] = [ | |
| 332 | − | { id: "computer:shell", group: "computer", label: "Shell", about: "Run commands on its own computer, with a persistent home, from its sessions.", kind: "write", tools: ["run_command"], status: "ready" }, | |
| 333 | − | { id: "computer:files", group: "computer", label: "Files", about: "Read and write files in its computer's home.", kind: "write", tools: ["computer_read_file", "computer_write_file"], status: "ready", default: "alone" }, | |
| 334 | − | { id: "computer:browser", group: "computer", label: "Browser", about: "Open sites in a browser of its own, signed in where you let it be.", kind: "send", tools: [], status: "coming" }, | |
| 335 | − | { id: "computer:web", group: "computer", label: "Web search", about: "Search and read the open web, as its team's web access allows.", kind: "read", tools: [], status: "coming" }, | |
| 336 | − | ]; | |
| 337 | − | ||
| 338 | − | /** The tools the computer offers, by ability id: what a session offers once the ability is not Never. */ | |
| 339 | − | export const COMPUTER_TOOLS: Record<string, string[]> = Object.fromEntries(COMPUTER_ABILITIES.filter((def) => def.status === "ready").map((def) => [def.id, def.tools])); | |
| 340 | − | ||
| 341 | − | // Integrations ----------------------------------------------------------- | |
| 342 | − | ||
| 343 | − | /** | |
| 344 | − | * What an agent can do through each connector today, as the integrations | |
| 345 | − | * service does it: look an item up by its key or address, open an issue in | |
| 346 | − | * g1t from it, comment on it there, and (Sentry) mark it resolved. A | |
| 347 | − | * connector not named here has nothing an agent calls: Datadog and the | |
| 348 | − | * alerts webhook open issues by themselves; GitHub imports repositories; | |
| 349 | − | * model providers run models. | |
| 350 | − | */ | |
| 351 | − | const INTEGRATION_ACTIONS: Record<string, { action: string; label: string; about: string; kind: AbilityKind; tool: string }[]> = { | |
| 352 | − | linear: [ | |
| 353 | − | { action: "read", label: "Read issues", about: "Look up an issue by its key (ENG-42) or address: its title, status and description.", kind: "read", tool: "lookup_outside" }, | |
| 354 | − | { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Linear issue, linked back to it.", kind: "write", tool: "import_outside" }, | |
| 355 | − | { action: "comment", label: "Comment", about: "Comment on an issue in Linear, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" }, | |
| 356 | − | ], | |
| 357 | − | jira: [ | |
| 358 | − | { action: "read", label: "Read tickets", about: "Look up a ticket by its key (TECH-1234) or address: its summary, status and description.", kind: "read", tool: "lookup_outside" }, | |
| 359 | − | { action: "import", label: "Import tickets", about: "Open an issue in a repository here from a Jira ticket, linked back to it.", kind: "write", tool: "import_outside" }, | |
| 360 | − | { action: "comment", label: "Comment", about: "Comment on a ticket in Jira, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" }, | |
| 361 | − | ], | |
| 362 | − | sentry: [ | |
| 363 | − | { action: "read", label: "Read issues", about: "Look up a Sentry issue by its address: its title, status and latest stack trace.", kind: "read", tool: "lookup_outside" }, | |
| 364 | − | { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Sentry issue, linked back to it.", kind: "write", tool: "import_outside" }, | |
| 365 | − | { action: "comment", label: "Comment", about: "Leave a note on a Sentry issue, as the workspace's connection.", kind: "send", tool: "act_outside" }, | |
| 366 | − | { action: "resolve", label: "Resolve issues", about: "Mark a Sentry issue resolved, with a note.", kind: "send", tool: "act_outside" }, | |
| 367 | − | ], | |
| 368 | − | }; | |
| 369 | − | ||
| 370 | − | /** What a connected connector with nothing for an agent to call says. */ | |
| 371 | − | function nothingToCall(connector: Connector): string { | |
| 372 | − | switch (connector.category) { | |
| 373 | − | case "monitoring": | |
| 374 | − | return "Opens issues by itself when something fires. Nothing for an agent to call."; | |
| 375 | − | case "ai": | |
| 376 | − | return "Runs models. Which ones an agent uses is set under Models on its profile."; | |
| 377 | − | case "code": | |
| 378 | − | return "Imports repositories. Agents read the repositories themselves."; | |
| 379 | − | default: | |
| 380 | − | return "Nothing for an agent to call yet."; | |
| 381 | − | } | |
| 382 | − | } | |
| 383 | − | ||
| 384 | − | /** The abilities one connector gives an agent; empty when it has none. */ | |
| 385 | − | export function integrationAbilities(connectorId: string): AbilityDef[] { | |
| 386 | − | return (INTEGRATION_ACTIONS[connectorId] ?? []).map((row) => ({ | |
| 387 | − | id: `integration:${connectorId}:${row.action}`, | |
| 388 | − | group: "integration", | |
| 389 | − | label: row.label, | |
| 390 | − | about: row.about, | |
| 391 | − | kind: row.kind, | |
| 392 | − | tools: [row.tool], | |
| 393 | − | status: "ready", | |
| 394 | − | })); | |
| 395 | − | } | |
| 396 | − | ||
| 397 | − | /** The connectors among `connectors` that give agents abilities, in catalog order. */ | |
| 398 | − | export function connectorsWithAbilities(connectors: Connector[]): Connector[] { | |
| 399 | − | return connectors.filter((connector) => (INTEGRATION_ACTIONS[connector.id] ?? []).length > 0); | |
| 400 | − | } | |
| 401 | − | ||
| 402 | − | // MCP servers ------------------------------------------------------------ | |
| 403 | − | ||
| 404 | − | /** An MCP tool as an ability of its server. */ | |
| 405 | − | export function mcpAbility(server: Pick<McpServer, "id" | "name">, tool: McpTool): AbilityDef { | |
| 406 | − | return { | |
| 407 | − | id: `mcp:${server.id}:${tool.name}`, | |
| 408 | − | group: "mcp", | |
| 409 | − | label: tool.name, | |
| 410 | − | about: tool.description || (tool.kind === "read" ? "Reads, as the server says." : "The server doesn't say it only reads, so it counts as a write."), | |
| 411 | − | // Anything an MCP server changes happens outside g1t. | |
| 412 | − | kind: tool.kind === "read" ? "read" : "send", | |
| 413 | − | tools: [mcpToolName(server.name, tool.name)], | |
| 414 | − | status: "ready", | |
| 415 | − | }; | |
| 416 | − | } | |
| 417 | − | ||
| 418 | − | /** The tool name an agent calls an MCP tool by: `<server>__<tool>`, in the characters the model API allows. */ | |
| 419 | − | export function mcpToolName(server: string, tool: string): string { | |
| 420 | − | const clean = (text: string) => text.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, ""); | |
| 421 | − | return `${clean(server)}__${clean(tool)}`.slice(0, 64); | |
| 422 | − | } | |
| 423 | − | ||
| 424 | − | /** An MCP server's name as kept: lowercase letters, digits and hyphens, 2 to 32. */ | |
| 425 | − | export function checkMcpName(value: unknown): { ok: true; value: string } | { ok: false; message: string } { | |
| 426 | − | const name = typeof value === "string" ? value.trim().toLowerCase() : ""; | |
| 427 | − | if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return { ok: false, message: "A server's name is 2 to 32 lowercase letters, digits and single hyphens." }; | |
| 428 | − | return { ok: true, value: name }; | |
| 429 | − | } | |
| 430 | − | ||
| 431 | − | /** | |
| 432 | − | * An MCP server's address, checked: HTTPS, a public host name (not an | |
| 433 | − | * address, not local, not g1t's own), no sign-in in the address. | |
| 434 | − | */ | |
| 435 | − | export function checkMcpUrl(value: unknown): { ok: true; value: string } | { ok: false; message: string } { | |
| 436 | − | const text = typeof value === "string" ? value.trim() : ""; | |
| 437 | − | if (!text || text.length > 500) return { ok: false, message: "Give the server's HTTPS address." }; | |
| 438 | − | let url: URL; | |
| 439 | − | try { | |
| 440 | − | url = new URL(text); | |
| 441 | − | } catch { | |
| 442 | − | return { ok: false, message: "That isn't an address." }; | |
| 443 | − | } | |
| 444 | − | if (url.protocol !== "https:") return { ok: false, message: "An MCP server is reached over HTTPS." }; | |
| 445 | − | if (url.username || url.password) return { ok: false, message: "Don't put a sign-in in the address." }; | |
| 446 | − | const host = url.hostname.toLowerCase(); | |
| 447 | − | const ip = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[") || host.includes(":"); | |
| 448 | − | const local = host === "localhost" || /\.(local|localhost|internal|lan|home|arpa)$/.test(host) || !host.includes("."); | |
| 449 | − | if (ip || local) return { ok: false, message: "Name the server by a public host name, not an address or a local name." }; | |
| 450 | − | if (/(^|\.)(g1t\.sh|g1tusercontent\.com)$/.test(host)) return { ok: false, message: "g1t's own addresses aren't MCP servers to add here." }; | |
| 451 | − | url.hash = ""; | |
| 452 | − | return { ok: true, value: url.toString() }; | |
| 453 | − | } | |
| 454 | − | ||
| 455 | − | // Resolving -------------------------------------------------------------- | |
| 456 | − | ||
| 457 | − | export type ResolveInput = { | |
| 458 | − | /** The connector catalog (./connectors.ts `CONNECTORS`). */ | |
| 459 | − | connectors: Connector[]; | |
| 460 | − | abilities: AgentAbilities | null | undefined; | |
| 461 | − | autonomy: AgentAutonomy; | |
| 462 | − | /** Connector ids the workspace has connected. */ | |
| 463 | − | connected: string[]; | |
| 464 | − | /** Where a connector is set up or managed, by id; absent: nowhere to link. */ | |
| 465 | − | hrefs?: Record<string, string | null>; | |
| 466 | − | /** Whether the agent is a member's personal one: then the workspace's connection isn't its to use unless an owner chose it. */ | |
| 467 | − | personal?: boolean; | |
| 468 | − | }; | |
| 469 | − | ||
| 470 | − | function settingOf(input: ResolveInput, id: string): AbilitySetting { | |
| 471 | − | return input.abilities?.settings?.[id] ?? {}; | |
| 472 | − | } | |
| 473 | − | ||
| 474 | − | /** A definition as one agent has it: its level, within what the kind allows. */ | |
| 475 | − | export function resolveOne(def: AbilityDef, input: ResolveInput, connected = true): Ability { | |
| 476 | − | const setting = settingOf(input, def.id); | |
| 477 | − | const max = maxLevel(def.kind); | |
| 478 | − | let level: AbilityLevel; | |
| 479 | − | let choices: AbilityLevel[]; | |
| 480 | − | let canChange: boolean; | |
| 481 | − | if (def.autonomy) { | |
| 482 | − | level = levelOfAutonomy(input.autonomy[def.autonomy.key]); | |
| 483 | − | choices = def.autonomy.levels; | |
| 484 | − | canChange = true; | |
| 485 | − | } else if (def.group === "g1t") { | |
| 486 | − | level = "alone"; | |
| 487 | − | choices = ["alone"]; | |
| 488 | − | canChange = false; | |
| 489 | − | } else if (def.status === "coming") { | |
| 490 | − | level = defaultLevel(def.kind); | |
| 491 | − | choices = []; | |
| 492 | − | canChange = false; | |
| 493 | − | } else { | |
| 494 | − | const wanted = setting.level ?? def.default ?? defaultLevel(def.kind); | |
| 495 | − | level = withinLevel(wanted, max) ? wanted : max; | |
| 496 | − | choices = ABILITY_LEVELS.filter((l) => withinLevel(l, max)); | |
| 497 | − | canChange = true; | |
| 498 | − | } | |
| 499 | − | const integration = def.group === "integration"; | |
| 500 | − | const connectorId = integration ? def.id.split(":")[1]! : null; | |
| 501 | − | const connector = connectorId ? input.connectors.find((c) => c.id === connectorId) : null; | |
| 502 | − | const personalAvailable = !!connector && connector.scopes.includes("personal") && (connector.personal?.status ?? connector.status) === "available"; | |
| 503 | − | return { | |
| 504 | − | ...def, | |
| 505 | − | level, | |
| 506 | − | max, | |
| 507 | − | choices, | |
| 508 | − | can_change: canChange && (def.group !== "integration" || connected), | |
| 509 | − | credentials: integration ? (setting.credentials ?? (input.personal ? "asker" : "workspace")) : null, | |
| 510 | − | personal_available: personalAvailable, | |
| 511 | − | }; | |
| 512 | − | } | |
| 513 | − | ||
| 514 | − | /** The abilities an agent has, in sections, as the tab shows them and the service enforces them. */ | |
| 515 | − | export function resolveAbilities(input: ResolveInput): AbilitySection[] { | |
| 516 | − | const connected = new Set(input.connected.map((id) => id.toLowerCase())); | |
| 517 | − | const hrefs = input.hrefs ?? {}; | |
| 518 | − | const sections: AbilitySection[] = [ | |
| 519 | − | { | |
| 520 | − | group: "g1t", | |
| 521 | − | title: "g1t", | |
| 522 | − | about: "Always on, within the access of the person it acts for. Code and docs keep the choices below.", | |
| 523 | − | sources: [{ id: "g1t", name: "g1t", connected: true, href: null, note: null, abilities: G1T_ABILITIES.map((def) => resolveOne(def, input)) }], | |
| 524 | − | }, | |
| 525 | − | { | |
| 526 | − | group: "computer", | |
| 527 | − | title: "Its computer", | |
| 528 | − | about: "A computer of its own on g1t cloud, used in its sessions: a shell and files now, a browser and web search coming. It sleeps when idle and keeps its home.", | |
| 529 | − | sources: [{ id: "computer", name: "Computer", connected: true, href: null, note: null, abilities: COMPUTER_ABILITIES.map((def) => resolveOne(def, input)) }], | |
| 530 | − | }, | |
| 531 | − | ]; | |
| 532 | − | // Every connected connector, then the ones with abilities that aren't connected yet. | |
| 533 | − | const sources: AbilitySource[] = []; | |
| 534 | − | for (const connector of input.connectors) { | |
| 535 | − | if (connector.status !== "available" || !connector.scopes.includes("workspace")) continue; | |
| 536 | − | const is = connected.has(connector.id); | |
| 537 | − | const defs = integrationAbilities(connector.id); | |
| 538 | − | if (!is && !defs.length) continue; | |
| 539 | − | // Nothing an agent calls: not worth a row unless it is connected. | |
| 540 | − | if (!defs.length && (connector.id === "webhooks" || connector.id === "ai-gateway")) continue; | |
| 541 | − | sources.push({ | |
| 542 | − | id: connector.id, | |
| 543 | − | name: connector.name, | |
| 544 | − | connected: is, | |
| 545 | − | href: hrefs[connector.id] ?? null, | |
| 546 | − | note: defs.length ? null : nothingToCall(connector), | |
| 547 | − | abilities: defs.map((def) => resolveOne(def, input, is)), | |
| 548 | − | }); | |
| 549 | − | } | |
| 550 | − | sources.sort((a, b) => Number(b.connected) - Number(a.connected)); | |
| 551 | − | sections.push({ group: "integration", title: "Integrations", about: "What it can do through what the workspace has connected, one row per action, and whose connection each runs on.", sources }); | |
| 552 | − | const servers = input.abilities?.mcp_servers ?? []; | |
| 553 | − | sections.push({ | |
| 554 | − | group: "mcp", | |
| 555 | − | title: "MCP servers", | |
| 556 | − | about: "Servers an owner adds. Each tool the server lists is a row; a tool that doesn't say it only reads counts as a write.", | |
| 557 | − | sources: servers.map((server) => ({ | |
| 558 | − | id: server.id, | |
| 559 | − | name: server.name, | |
| 560 | − | connected: true, | |
| 561 | − | href: server.url, | |
| 562 | − | note: server.problem ?? (server.tools.length ? null : "It listed no tools."), | |
| 563 | − | abilities: server.tools.slice(0, MAX_MCP_TOOLS).map((tool) => resolveOne(mcpAbility(server, tool), input)), | |
| 564 | − | })), | |
| 565 | − | }); | |
| 566 | − | return sections; | |
| 567 | − | } | |
| 568 | − | ||
| 569 | − | /** Every ability in the sections, flat. */ | |
| 570 | − | export function allAbilities(sections: AbilitySection[]): Ability[] { | |
| 571 | − | return sections.flatMap((section) => section.sources.flatMap((source) => source.abilities)); | |
| 572 | − | } | |
| 573 | − | ||
| 574 | − | /** The ability by id, among the sections; null when there is none. */ | |
| 575 | − | export function findAbility(sections: AbilitySection[], id: string): { ability: Ability; source: AbilitySource } | null { | |
| 576 | − | for (const section of sections) { | |
| 577 | − | for (const source of section.sources) { | |
| 578 | − | const ability = source.abilities.find((a) => a.id === id); | |
| 579 | − | if (ability) return { ability, source }; | |
| 580 | − | } | |
| 581 | − | } | |
| 582 | − | return null; | |
| 583 | − | } | |
| 584 | − | ||
| 585 | − | /** | |
| 586 | − | * Whether what the person said asked for this: the item's key (ENG-42), or | |
| 587 | − | * the ability's own name, appears in it. For a level of `asked`. | |
| 588 | − | */ | |
| 589 | − | export function askedFor(said: string, keys: string[]): boolean { | |
| 590 | − | const text = said.toLowerCase(); | |
| 591 | − | if (!text.trim()) return false; | |
| 592 | − | return keys.some((key) => { | |
| 593 | − | const needle = key.trim().toLowerCase(); | |
| 594 | − | return needle.length >= 2 && text.includes(needle); | |
| 595 | − | }); | |
| 596 | − | } | |
| 597 | − | ||
| 598 | − | /** A list in words: "a", "a and b", "a, b and c". */ | |
| 599 | − | function list(items: string[]): string { | |
| 600 | − | if (items.length <= 1) return items.join(""); | |
| 601 | − | return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; | |
| 602 | − | } | |
| 603 | − | ||
| 604 | − | const lower = (text: string) => text.charAt(0).toLowerCase() + text.slice(1); | |
| 605 | − | ||
| 606 | − | /** | |
| 607 | − | * The agent's abilities in a sentence or two: "Can read Linear issues and | |
| 608 | − | * open pull requests on its own; imports Linear issues when asked for it; | |
| 609 | − | * asks before commenting in Linear and merging; never deploys to | |
| 610 | − | * production." Only what is connected and ready counts. | |
| 611 | − | */ | |
| 612 | − | export function abilitiesSummary(sections: AbilitySection[]): string { | |
| 613 | − | const by: Record<AbilityLevel, string[]> = { alone: [], asked: [], ask: [], never: [] }; | |
| 614 | − | for (const section of sections) { | |
| 615 | − | for (const source of section.sources) { | |
| 616 | − | if (!source.connected) continue; | |
| 617 | − | for (const ability of source.abilities) { | |
| 618 | − | if (ability.status !== "ready") continue; | |
| 619 | − | // g1t's always-on reads go without saying; its choices and everything outside are the point. | |
| 620 | − | if (section.group === "g1t" && !ability.autonomy) continue; | |
| 621 | − | const what = | |
| 622 | − | section.group === "g1t" | |
| 623 | − | ? lower(ability.label) | |
| 624 | − | : section.group === "computer" | |
| 625 | − | ? computerWords(ability.id) | |
| 626 | − | : section.group === "mcp" | |
| 627 | − | ? `call ${ability.label} on ${source.name}` | |
| 628 | − | : `${lower(ability.label)} in ${source.name}`; | |
| 629 | − | by[ability.level].push(what); | |
| 630 | − | } | |
| 631 | − | } | |
| 632 | − | } | |
| 633 | − | const parts: string[] = []; | |
| 634 | − | if (by.alone.length) parts.push(`can ${list(by.alone)} on its own`); | |
| 635 | − | if (by.asked.length) parts.push(`${list(by.asked.map(verb))} when asked for it`); | |
| 636 | − | if (by.ask.length) parts.push(`asks before ${list(by.ask.map(gerund))}`); | |
| 637 | − | if (by.never.length) parts.push(`never ${list(by.never.map(verb))}`); | |
| 638 | − | if (!parts.length) return "Reads code, chat, issues and artifacts within the asker's access; nothing outside g1t yet."; | |
| 639 | − | const text = parts.join("; "); | |
| 640 | − | return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; | |
| 641 | − | } | |
| 642 | − | ||
| 643 | − | /** A computer ability as what the agent does with it. */ | |
| 644 | − | function computerWords(id: string): string { | |
| 645 | − | return id === "computer:shell" ? "run commands on its computer" : "change files on its computer"; | |
| 646 | − | } | |
| 647 | − | ||
| 648 | − | /** "read issues in Linear" → "reads issues in Linear". */ | |
| 649 | − | function verb(what: string): string { | |
| 650 | − | const [first, ...rest] = what.split(" "); | |
| 651 | − | if (!first) return what; | |
| 652 | − | const third = first.endsWith("s") ? first : first.endsWith("y") && !/[aeiou]y$/.test(first) ? `${first.slice(0, -1)}ies` : `${first}s`; | |
| 653 | − | return [third, ...rest].join(" "); | |
| 654 | − | } | |
| 655 | − | ||
| 656 | − | /** "comment in Linear" → "commenting in Linear". */ | |
| 657 | − | function gerund(what: string): string { | |
| 658 | − | const [first, ...rest] = what.split(" "); | |
| 659 | − | if (!first) return what; | |
| 660 | − | const ing = first.endsWith("e") && first !== "be" ? `${first.slice(0, -1)}ing` : first.endsWith("ing") ? first : `${first}ing`; | |
| 661 | − | return [ing, ...rest].join(" "); | |
| 662 | − | } | |
| 663 | − | ||
| 664 | − | /** The settings with one ability's level or credentials changed, keeping only what differs from the default. */ | |
| 665 | − | export function withSetting(abilities: AgentAbilities | null | undefined, id: string, change: AbilitySetting): AgentAbilities { | |
| 666 | − | const base = abilities ?? EMPTY_ABILITIES; | |
| 667 | − | const current = { ...(base.settings[id] ?? {}) }; | |
| 668 | − | if (change.level !== undefined) current.level = change.level; | |
| 669 | − | if (change.credentials !== undefined) current.credentials = change.credentials; | |
| 670 | − | const next = { ...base.settings }; | |
| 671 | − | const kept: AbilitySetting = {}; | |
| 672 | − | if (current.level) kept.level = current.level; | |
| 673 | − | if (current.credentials) kept.credentials = current.credentials; | |
| 674 | − | if (Object.keys(kept).length) next[id] = kept; | |
| 675 | − | else delete next[id]; | |
| 676 | − | return { settings: next, mcp_servers: base.mcp_servers ?? [] }; | |
| 677 | − | } |
| 1 | − | /** | |
| 2 | − | * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): the little bot | |
| 3 | − | * every agent wears when it has no uploaded picture. A face is a `look`, | |
| 4 | − | * seven choices the agent's owner can make; an agent with no look of its | |
| 5 | − | * own wears the one drawn from its `avatar_seed`, so every agent has a | |
| 6 | − | * stable face from the moment it is made, and keeps it through a rename. | |
| 7 | − | * | |
| 8 | − | * The site draws the face (apps/web/app/components/agent-face.tsx) and the | |
| 9 | − | * agents service keeps it (the `look` column, JSON). No value imports, so | |
| 10 | − | * services test it under Node as it is. Wire shapes are snake_case. | |
| 11 | − | */ | |
| 12 | − | ||
| 13 | − | export const FACE_SHAPES = ["round", "square", "squircle", "blob", "hex"] as const; | |
| 14 | − | /** Ten named colours, each with a light and a dark rendering that read on both themes. */ | |
| 15 | − | export const FACE_COLORS = ["lavender", "mint", "peach", "sky", "pink", "lemon", "teal", "coral", "moss", "slate"] as const; | |
| 16 | − | export const FACE_EYES = ["dots", "round", "wide", "happy", "visor", "sleepy"] as const; | |
| 17 | − | export const FACE_MOUTHS = ["smile", "grin", "flat", "open", "none"] as const; | |
| 18 | − | export const FACE_ANTENNAS = ["none", "single", "double", "bulb"] as const; | |
| 19 | − | export const FACE_ACCESSORIES = ["none", "headphones", "bow", "glasses", "hat", "spark"] as const; | |
| 20 | − | export const FACE_PATTERNS = ["none", "stripe", "dots", "gradient"] as const; | |
| 21 | − | ||
| 22 | − | export type FaceShape = (typeof FACE_SHAPES)[number]; | |
| 23 | − | export type FaceColor = (typeof FACE_COLORS)[number]; | |
| 24 | − | export type FaceEyes = (typeof FACE_EYES)[number]; | |
| 25 | − | export type FaceMouth = (typeof FACE_MOUTHS)[number]; | |
| 26 | − | export type FaceAntenna = (typeof FACE_ANTENNAS)[number]; | |
| 27 | − | export type FaceAccessory = (typeof FACE_ACCESSORIES)[number]; | |
| 28 | − | export type FacePattern = (typeof FACE_PATTERNS)[number]; | |
| 29 | − | ||
| 30 | − | /** What an agent's face is made of. Every field is one of its listed choices. */ | |
| 31 | − | export type AgentLook = { | |
| 32 | − | shape: FaceShape; | |
| 33 | − | color: FaceColor; | |
| 34 | − | eyes: FaceEyes; | |
| 35 | − | mouth: FaceMouth; | |
| 36 | − | antenna: FaceAntenna; | |
| 37 | − | accessory: FaceAccessory; | |
| 38 | − | pattern: FacePattern; | |
| 39 | − | }; | |
| 40 | − | ||
| 41 | − | /** Each part of a look and its choices, in the order the Face editor shows them. */ | |
| 42 | − | export const LOOK_OPTIONS: { [K in keyof AgentLook]: readonly AgentLook[K][] } = { | |
| 43 | − | shape: FACE_SHAPES, | |
| 44 | − | color: FACE_COLORS, | |
| 45 | − | eyes: FACE_EYES, | |
| 46 | − | mouth: FACE_MOUTHS, | |
| 47 | − | antenna: FACE_ANTENNAS, | |
| 48 | − | accessory: FACE_ACCESSORIES, | |
| 49 | − | pattern: FACE_PATTERNS, | |
| 50 | − | }; | |
| 51 | − | ||
| 52 | − | export const LOOK_KEYS = Object.keys(LOOK_OPTIONS) as (keyof AgentLook)[]; | |
| 53 | − | ||
| 54 | − | /** FNV-1a, 32 bits: a seed as a number. */ | |
| 55 | − | function fnv(text: string): number { | |
| 56 | − | let h = 0x811c9dc5; | |
| 57 | − | for (let i = 0; i < text.length; i++) { | |
| 58 | − | h ^= text.charCodeAt(i); | |
| 59 | − | h = Math.imul(h, 0x01000193); | |
| 60 | − | } | |
| 61 | − | return h >>> 0; | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | /** One choice for a part, from the seed and the part's name: parts vary independently. */ | |
| 65 | − | function pick<T>(seed: string, part: string, options: readonly T[], weights?: readonly number[]): T { | |
| 66 | − | const roll = fnv(`${part}:${seed}`) >>> 8; | |
| 67 | − | if (!weights) return options[roll % options.length]!; | |
| 68 | − | const total = weights.reduce((sum, w) => sum + w, 0); | |
| 69 | − | let at = roll % total; | |
| 70 | − | for (let i = 0; i < options.length; i++) { | |
| 71 | − | at -= weights[i]!; | |
| 72 | − | if (at < 0) return options[i]!; | |
| 73 | − | } | |
| 74 | − | return options[options.length - 1]!; | |
| 75 | − | } | |
| 76 | − | ||
| 77 | − | /** | |
| 78 | − | * The face an agent wears when it has chosen none: the same for the same | |
| 79 | − | * seed everywhere, and different from its neighbours' as far as seven | |
| 80 | − | * parts allow. Plainer choices (no antenna, no accessory, no pattern) are | |
| 81 | − | * weighted so faces read as a set rather than a costume party, and `none` | |
| 82 | − | * never lands on both the eyes' expression and the mouth. | |
| 83 | − | */ | |
| 84 | − | export function lookFromSeed(seed: string): AgentLook { | |
| 85 | − | const s = seed || "agent"; | |
| 86 | − | const look: AgentLook = { | |
| 87 | − | shape: pick(s, "shape", FACE_SHAPES, [3, 2, 4, 2, 2]), | |
| 88 | − | color: pick(s, "color", FACE_COLORS), | |
| 89 | − | eyes: pick(s, "eyes", FACE_EYES, [3, 3, 2, 2, 2, 1]), | |
| 90 | − | mouth: pick(s, "mouth", FACE_MOUTHS, [4, 2, 2, 2, 1]), | |
| 91 | − | antenna: pick(s, "antenna", FACE_ANTENNAS, [4, 3, 2, 2]), | |
| 92 | − | accessory: pick(s, "accessory", FACE_ACCESSORIES, [7, 1, 1, 1, 1, 1]), | |
| 93 | − | pattern: pick(s, "pattern", FACE_PATTERNS, [6, 1, 1, 2]), | |
| 94 | − | }; | |
| 95 | − | if (look.mouth === "none" && look.eyes === "visor") look.mouth = "flat"; | |
| 96 | − | return look; | |
| 97 | − | } | |
| 98 | − | ||
| 99 | − | /** | |
| 100 | − | * A look as it was sent or stored, checked: every part present and one of | |
| 101 | − | * its choices. Null when it is not one (a service refuses it, a page draws | |
| 102 | − | * the seed's face instead). | |
| 103 | − | */ | |
| 104 | − | export function readLook(raw: unknown): AgentLook | null { | |
| 105 | − | const value = typeof raw === "string" ? parse(raw) : raw; | |
| 106 | − | if (!value || typeof value !== "object" || Array.isArray(value)) return null; | |
| 107 | − | const given = value as Record<string, unknown>; | |
| 108 | − | const look: Partial<AgentLook> = {}; | |
| 109 | − | for (const key of LOOK_KEYS) { | |
| 110 | − | const options = LOOK_OPTIONS[key] as readonly string[]; | |
| 111 | − | const choice = given[key]; | |
| 112 | − | if (typeof choice !== "string" || !options.includes(choice)) return null; | |
| 113 | − | (look as Record<string, string>)[key] = choice; | |
| 114 | − | } | |
| 115 | − | return look as AgentLook; | |
| 116 | − | } | |
| 117 | − | ||
| 118 | − | function parse(text: string): unknown { | |
| 119 | − | try { | |
| 120 | − | return JSON.parse(text); | |
| 121 | − | } catch { | |
| 122 | − | return null; | |
| 123 | − | } | |
| 124 | − | } | |
| 125 | − | ||
| 126 | − | /** Whether two looks are the same face. */ | |
| 127 | − | export function sameLook(a: AgentLook | null | undefined, b: AgentLook | null | undefined): boolean { | |
| 128 | − | if (!a || !b) return a == b; | |
| 129 | − | return LOOK_KEYS.every((key) => a[key] === b[key]); | |
| 130 | − | } | |
| 131 | − | ||
| 132 | − | /** The face an agent shows: its own look, else the one its seed draws. */ | |
| 133 | − | export function lookOf(agent: { look?: AgentLook | null; avatar_seed?: string | null; id?: string | null; handle?: string | null; name?: string | null }): AgentLook { | |
| 134 | − | return agent.look ?? lookFromSeed(agent.avatar_seed || agent.id || agent.handle || agent.name || "agent"); | |
| 135 | − | } | |
| 136 | − | ||
| 137 | − | /** | |
| 138 | − | * A random look, for the Shuffle button: `random` is `Math.random` unless | |
| 139 | − | * a test hands in its own. Every part is drawn evenly, so shuffling shows | |
| 140 | − | * the whole range. | |
| 141 | − | */ | |
| 142 | − | export function randomLook(random: () => number = Math.random): AgentLook { | |
| 143 | − | const draw = <T>(options: readonly T[]): T => options[Math.min(options.length - 1, Math.floor(random() * options.length))]!; | |
| 144 | − | return { | |
| 145 | − | shape: draw(FACE_SHAPES), | |
| 146 | − | color: draw(FACE_COLORS), | |
| 147 | − | eyes: draw(FACE_EYES), | |
| 148 | − | mouth: draw(FACE_MOUTHS), | |
| 149 | − | antenna: draw(FACE_ANTENNAS), | |
| 150 | − | accessory: draw(FACE_ACCESSORIES), | |
| 151 | − | pattern: draw(FACE_PATTERNS), | |
| 152 | − | }; | |
| 153 | − | } | |
| 154 | − | ||
| 155 | − | /** | |
| 156 | − | * What a personality preset suggests for a face that has not been chosen: | |
| 157 | − | * a precise voice looks precise. Only a suggestion, applied by the Face | |
| 158 | − | * editor when the person has made no choice; it never changes a look an | |
| 159 | − | * agent already has. | |
| 160 | − | */ | |
| 161 | − | export const PRESET_EXPRESSIONS: Record<string, Partial<AgentLook>> = { | |
| 162 | − | crisp: { eyes: "dots", mouth: "flat" }, | |
| 163 | − | friendly: { eyes: "happy", mouth: "smile" }, | |
| 164 | − | socratic: { eyes: "round", mouth: "open" }, | |
| 165 | − | terse: { eyes: "visor", mouth: "flat" }, | |
| 166 | − | }; | |
| 167 | − | ||
| 168 | − | /** The seed's face with the preset's expression laid over it, for an agent that has chosen no look. */ | |
| 169 | − | export function suggestedLook(seed: string, preset: string | null | undefined): AgentLook { | |
| 170 | − | return { ...lookFromSeed(seed), ...(preset ? (PRESET_EXPRESSIONS[preset] ?? {}) : {}) }; | |
| 171 | − | } |
| 1 | − | /** | |
| 2 | − | * The context hub: one place to ask about a workspace. The context service | |
| 3 | − | * keeps a **catalog** of what the workspace builds and runs (projects, | |
| 4 | − | * their apps, APIs, packages, languages, owners, environments, integrations | |
| 5 | − | * and docs, and how they relate), built by itself from the repositories and | |
| 6 | − | * from projects, deployments and integrations; **search** across the | |
| 7 | − | * catalog, docs, issues, pull requests and memory; and **scorecards** for | |
| 8 | − | * each project. Memory itself, candidates and their review, is kept by | |
| 9 | − | * the work service: see `memoryReviewClient`. | |
| 10 | − | */ | |
| 11 | − | ||
| 12 | − | import type { Memory, MemoryKind } from "./agents"; | |
| 13 | − | import type { ServiceBinding } from "./clients"; | |
| 14 | − | import type { User, Viewer } from "./identity"; | |
| 15 | − | import type { RepoPath } from "./repos"; | |
| 16 | − | import type { Result } from "./result"; | |
| 17 | − | ||
| 18 | − | export type EntityKind = | |
| 19 | − | | "project" | |
| 20 | − | | "app" | |
| 21 | − | | "api" | |
| 22 | − | | "package" | |
| 23 | − | | "language" | |
| 24 | − | | "owner" | |
| 25 | − | | "environment" | |
| 26 | − | | "integration" | |
| 27 | − | | "doc"; | |
| 28 | − | ||
| 29 | − | export const ENTITY_KINDS: EntityKind[] = ["project", "app", "api", "package", "language", "owner", "environment", "integration", "doc"]; | |
| 30 | − | ||
| 31 | − | export type RelationKind = "depends_on" | "owned_by" | "deploys_to" | "documented_by" | "exposes" | "uses"; | |
| 32 | − | ||
| 33 | − | export type Entity = { | |
| 34 | − | id: string; | |
| 35 | − | workspace: string; | |
| 36 | − | kind: EntityKind; | |
| 37 | − | /** Unique for its kind in the workspace: a project's slug, `npm:<name>`, a username. */ | |
| 38 | − | key: string; | |
| 39 | − | name: string; | |
| 40 | − | summary: string | null; | |
| 41 | − | /** The project it belongs to, by slug; null for what the workspace shares (owners, languages, integrations). */ | |
| 42 | − | project: string | null; | |
| 43 | − | /** Whether only members see it: it comes from a private repository. */ | |
| 44 | − | private: boolean; | |
| 45 | − | /** What it is, by kind: a package's version and dependencies, an API's routes, an app's address. */ | |
| 46 | − | data: Record<string, unknown>; | |
| 47 | − | /** Where it was found: `scan` (the repository), `projects`, `deployments`, `integrations`. */ | |
| 48 | − | source: string; | |
| 49 | − | /** Where to see it: a path on g1t.sh, or an address. */ | |
| 50 | − | ref: string | null; | |
| 51 | − | /** RFC 3339. */ | |
| 52 | − | updatedAt: string; | |
| 53 | − | }; | |
| 54 | − | ||
| 55 | − | export type Relation = { from: string; kind: RelationKind; to: string }; | |
| 56 | − | ||
| 57 | − | export type Catalog = { | |
| 58 | − | entities: Entity[]; | |
| 59 | − | relations: Relation[]; | |
| 60 | − | /** When the catalog was last built from the repositories. */ | |
| 61 | − | builtAt: string | null; | |
| 62 | − | }; | |
| 63 | − | ||
| 64 | − | export type EntityDetail = { | |
| 65 | − | entity: Entity; | |
| 66 | − | /** Each relation with the entity at its other end. */ | |
| 67 | − | relations: { kind: RelationKind; direction: "out" | "in"; entity: Entity }[]; | |
| 68 | − | }; | |
| 69 | − | ||
| 70 | − | /** What search can return: a catalog entity's kind, or one of these. */ | |
| 71 | − | export type SearchKind = EntityKind | "memory" | "issue" | "pull"; | |
| 72 | − | export const SEARCH_KINDS: SearchKind[] = [...ENTITY_KINDS, "memory", "issue", "pull"]; | |
| 73 | − | ||
| 74 | − | export type SearchHit = { | |
| 75 | − | kind: SearchKind; | |
| 76 | − | id: string; | |
| 77 | − | title: string; | |
| 78 | − | snippet: string; | |
| 79 | − | /** The project it is about, by slug. */ | |
| 80 | − | project: string | null; | |
| 81 | − | /** Where to see it on g1t.sh, or an address. */ | |
| 82 | − | url: string | null; | |
| 83 | − | /** Higher is closer; text matches score below semantic ones. */ | |
| 84 | − | score: number; | |
| 85 | − | /** Where it came from, such as `AGENTS.md`, `review on #12`, `catalog`. */ | |
| 86 | − | source: string; | |
| 87 | − | /** Who wrote it, for memory, issues and pull requests. */ | |
| 88 | − | by: string | null; | |
| 89 | − | /** RFC 3339. */ | |
| 90 | − | updatedAt: string | null; | |
| 91 | − | }; | |
| 92 | − | ||
| 93 | − | export type SearchResult = { | |
| 94 | − | query: string; | |
| 95 | − | hits: SearchHit[]; | |
| 96 | − | /** `semantic` when the search index answered; `text` when it fell back to matching words. */ | |
| 97 | − | mode: "semantic" | "text"; | |
| 98 | − | }; | |
| 99 | − | ||
| 100 | − | export type SearchOptions = { project?: string | null; kinds?: SearchKind[] | null; limit?: number | null }; | |
| 101 | − | ||
| 102 | − | export type ScoreRule = "has_owner" | "has_readme" | "has_agents_md" | "tests_in_ci" | "production_green" | "no_secret_findings"; | |
| 103 | − | ||
| 104 | − | export type RuleResult = { | |
| 105 | − | rule: ScoreRule; | |
| 106 | − | title: string; | |
| 107 | − | /** `na` when the rule does not apply, such as production for a project that does not deploy. */ | |
| 108 | − | status: "pass" | "fail" | "na"; | |
| 109 | − | detail: string; | |
| 110 | − | /** For a failing rule: an issue an agent can fix it from. */ | |
| 111 | − | fix: { title: string; body: string; checks: string[] } | null; | |
| 112 | − | }; | |
| 113 | − | ||
| 114 | − | export type Scorecard = { | |
| 115 | − | project: string; | |
| 116 | − | name: string; | |
| 117 | − | repo: RepoPath; | |
| 118 | − | passed: number; | |
| 119 | − | /** The rules that apply. */ | |
| 120 | − | total: number; | |
| 121 | − | rules: RuleResult[]; | |
| 122 | − | }; | |
| 123 | − | ||
| 124 | − | export type Backfill = { | |
| 125 | − | workspace: string; | |
| 126 | − | status: "running" | "done" | "failed"; | |
| 127 | − | by: string; | |
| 128 | − | projects: number; | |
| 129 | − | done: number; | |
| 130 | − | entities: number; | |
| 131 | − | /** Memory candidates added, and how many of them were kept at once. */ | |
| 132 | − | candidates: number; | |
| 133 | − | kept: number; | |
| 134 | − | /** Things put in the search index. */ | |
| 135 | − | indexed: number; | |
| 136 | − | error: string | null; | |
| 137 | − | startedAt: string; | |
| 138 | − | finishedAt: string | null; | |
| 139 | − | }; | |
| 140 | − | ||
| 141 | − | export type ContextStatus = { | |
| 142 | − | backfill: Backfill | null; | |
| 143 | − | counts: Partial<Record<EntityKind, number>>; | |
| 144 | − | /** What the search index cost the workspace this month. */ | |
| 145 | − | usage: { month: string; tokens: number; costMicros: number }; | |
| 146 | − | /** Whether semantic search is available: set up, and on a paid plan or the trial. Text search always is. */ | |
| 147 | − | semantic: boolean; | |
| 148 | − | }; | |
| 149 | − | ||
| 150 | − | export type RunContext = { | |
| 151 | − | /** The Context section for an agent's prompt; null when there is nothing to say. */ | |
| 152 | − | text: string | null; | |
| 153 | − | /** What it drew on, such as `catalog:web`, `memory:mem_…`. */ | |
| 154 | − | sources: string[]; | |
| 155 | − | }; | |
| 156 | − | ||
| 157 | − | export interface ContextApi { | |
| 158 | − | /** The workspace's catalog, by kind and project. Members, or anyone for public projects' entries. */ | |
| 159 | − | catalog(workspace: string, viewer: Viewer, filter?: { kind?: EntityKind | null; project?: string | null }): Promise<Result<Catalog>>; | |
| 160 | − | /** One entity, by its id or its key, with its relations. */ | |
| 161 | − | entity(workspace: string, viewer: Viewer, kind: EntityKind, id: string): Promise<Result<EntityDetail>>; | |
| 162 | − | /** One search across the catalog, docs, issues, pull requests and, for members, memory. */ | |
| 163 | − | search(workspace: string, viewer: Viewer, query: string, options?: SearchOptions): Promise<Result<SearchResult>>; | |
| 164 | − | /** Each project's scorecard. Members only. */ | |
| 165 | − | scorecards(workspace: string, viewer: Viewer): Promise<Result<Scorecard[]>>; | |
| 166 | − | /** Builds the catalog for every project and seeds memory from docs and merged pull requests. Members only; once at a time. */ | |
| 167 | − | backfill(actor: User, workspace: string): Promise<Result<Backfill>>; | |
| 168 | − | /** The hub's state for a workspace. Starts its first backfill when it has none. Members only. */ | |
| 169 | − | status(workspace: string, viewer: Viewer): Promise<Result<ContextStatus>>; | |
| 170 | − | /** | |
| 171 | − | * For the runner: the Context section for an agent starting work in a | |
| 172 | − | * repository, within `budget` characters. `requester` is the person the | |
| 173 | − | * run acts for: it holds only what they may read. One who is not a member | |
| 174 | − | * of the workspace gets the project's memory only, never the workspace's, | |
| 175 | − | * and only the dependencies whose projects they can read. | |
| 176 | − | */ | |
| 177 | − | runContext(repoId: string, task: string, budget?: number, requester?: Viewer): Promise<RunContext>; | |
| 178 | − | } | |
| 179 | − | ||
| 180 | − | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| 181 | − | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 182 | − | method: "POST", | |
| 183 | − | headers: { "content-type": "application/json" }, | |
| 184 | − | body: JSON.stringify(args), | |
| 185 | − | }); | |
| 186 | − | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 187 | − | return (await response.json()) as T; | |
| 188 | − | } | |
| 189 | − | ||
| 190 | − | export function contextClient(service: ServiceBinding): ContextApi { | |
| 191 | − | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 192 | − | return { | |
| 193 | − | catalog: (workspace, viewer, filter = {}) => call("catalog", { workspace, viewer, ...filter }), | |
| 194 | − | entity: (workspace, viewer, kind, id) => call("entity", { workspace, viewer, kind, id }), | |
| 195 | − | search: (workspace, viewer, query, options = {}) => call("search", { workspace, viewer, query, ...options }), | |
| 196 | − | scorecards: (workspace, viewer) => call("scorecards", { workspace, viewer }), | |
| 197 | − | backfill: (actor, workspace) => call("backfill", { actor, workspace }), | |
| 198 | − | status: (workspace, viewer) => call("status", { workspace, viewer }), | |
| 199 | − | runContext: (repoId, task, budget, requester) => call("run_context", { repoId, task, budget, requester: requester ?? null }), | |
| 200 | − | }; | |
| 201 | − | } | |
| 202 | − | ||
| 203 | − | // --- Memory capture and review (the work service) --------------------------- | |
| 204 | − | ||
| 205 | − | /** Where a captured memory came from. */ | |
| 206 | − | export type CaptureSource = "run" | "review" | "pr" | "doc" | "manual"; | |
| 207 | − | ||
| 208 | − | export type CaptureItem = { | |
| 209 | − | scope: "project" | "workspace"; | |
| 210 | − | /** For a project's memory: its repository's id. */ | |
| 211 | − | repoId?: string | null; | |
| 212 | − | kind?: MemoryKind; | |
| 213 | − | text: string; | |
| 214 | − | confidence?: number | null; | |
| 215 | − | source: CaptureSource; | |
| 216 | − | /** One source: `doc:<repo id>:<path>`, `run:<id>`, `comment:<id>`, `pull:<repo id>#<n>`. */ | |
| 217 | − | reference: string; | |
| 218 | − | evidence?: string | null; | |
| 219 | − | number?: number | null; | |
| 220 | − | runId?: string | null; | |
| 221 | − | }; | |
| 222 | − | ||
| 223 | − | export type Captured = { added: number; merged: number; kept: number; refused: number }; | |
| 224 | − | ||
| 225 | − | export type ReviewDecision = "keep" | "dismiss"; | |
| 226 | − | ||
| 227 | − | export interface MemoryReviewApi { | |
| 228 | − | /** Candidates waiting for review in a workspace, or for one project. Members only. */ | |
| 229 | − | listCandidates(viewer: Viewer, workspace: string, repo?: RepoPath | null): Promise<Result<Memory[]>>; | |
| 230 | − | /** Keep a candidate, edited or as it is, or dismiss it. Members only. */ | |
| 231 | − | reviewMemory( | |
| 232 | − | actor: User, | |
| 233 | − | workspace: string, | |
| 234 | − | id: string, | |
| 235 | − | decision: ReviewDecision, | |
| 236 | − | change?: { text?: string; kind?: MemoryKind }, | |
| 237 | − | ): Promise<Result<Memory>>; | |
| 238 | − | /** For services: candidates from docs and backfills. */ | |
| 239 | − | captureMemories(workspace: string, items: CaptureItem[], by?: string): Promise<Captured>; | |
| 240 | − | /** | |
| 241 | − | * For services: removes a project's candidates that came only from its | |
| 242 | − | * docs and are still waiting, unless one of `texts` (what its docs suggest | |
| 243 | − | * now) says the same thing. Kept and dismissed memory is never touched. | |
| 244 | − | */ | |
| 245 | − | pruneDocCandidates(workspace: string, repoId: string, texts: string[]): Promise<{ removed: number }>; | |
| 246 | − | /** For services: memories by id, in any status. */ | |
| 247 | − | memoriesById(workspace: string, ids: string[]): Promise<Memory[]>; | |
| 248 | − | /** For services: kept memories with every word of `query`; the caller has checked the viewer may read them. */ | |
| 249 | − | searchMemories(workspace: string, query: string | null, options?: { repoIds?: string[] | null; limit?: number }): Promise<Memory[]>; | |
| 250 | − | /** For services: decision and convention candidates from a repository's last merged pull requests. */ | |
| 251 | − | seedFromPulls(repoId: string, limit?: number): Promise<Captured>; | |
| 252 | − | } | |
| 253 | − | ||
| 254 | − | /** Memory capture and review: methods of the work service. */ | |
| 255 | − | export function memoryReviewClient(service: ServiceBinding): MemoryReviewApi { | |
| 256 | − | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 257 | − | return { | |
| 258 | − | listCandidates: (viewer, workspace, repo) => call("list_candidates", { viewer, workspace, repo: repo ?? null }), | |
| 259 | − | reviewMemory: (actor, workspace, id, decision, change = {}) => call("review_memory", { actor, workspace, id, decision, ...change }), | |
| 260 | − | captureMemories: (workspace, items, by) => call("capture_memories", { workspace, items, by: by ?? null }), | |
| 261 | − | pruneDocCandidates: (workspace, repoId, texts) => call("prune_doc_candidates", { workspace, repoId, texts }), | |
| 262 | − | memoriesById: (workspace, ids) => call("memories_by_id", { workspace, ids }), | |
| 263 | − | searchMemories: (workspace, query, options = {}) => | |
| 264 | − | call("search_memories", { workspace, query, repoIds: options.repoIds ?? null, limit: options.limit ?? 20 }), | |
| 265 | − | seedFromPulls: (repoId, limit) => call("seed_from_pulls", { repoId, limit: limit ?? null }), | |
| 266 | − | }; | |
| 267 | − | } |
| 1 | − | /** | |
| 2 | − | * `@g1t` in comments, and rules that put g1t to work by | |
| 3 | − | * itself. Kept by the work service; mirrors `services/work/src/mentions.rs`. | |
| 4 | − | */ | |
| 5 | − | import type { ServiceBinding } from "./clients"; | |
| 6 | − | import type { User, Viewer } from "./identity"; | |
| 7 | − | import type { RepoPath } from "./repos"; | |
| 8 | − | import type { Result } from "./result"; | |
| 9 | − | import type { LifecycleJob, PullStatus } from "./work"; | |
| 10 | − | ||
| 11 | − | /** How g1t's agent is mentioned in a comment. */ | |
| 12 | − | export const AGENT_HANDLE = "@g1t"; | |
| 13 | − | ||
| 14 | − | /** What someone who mentioned `@g1t` wants. */ | |
| 15 | − | export type MentionIntent = "work" | "question" | "review"; | |
| 16 | − | ||
| 17 | − | /** What the runner needs to act on a comment that mentioned `@g1t`. */ | |
| 18 | − | export type MentionJob = { | |
| 19 | − | commentId: string; | |
| 20 | − | /** Who wrote it, with the memberships they had then. */ | |
| 21 | − | actor: User; | |
| 22 | − | repo: RepoPath; | |
| 23 | − | number: number; | |
| 24 | − | /** The comment as written. */ | |
| 25 | − | body: string; | |
| 26 | − | intent: MentionIntent; | |
| 27 | − | /** Whether they belong to the repository's workspace. */ | |
| 28 | − | member: boolean; | |
| 29 | − | defaultBranch: string; | |
| 30 | − | /** Set when the comment is on an issue: whether it is still open. */ | |
| 31 | − | issueOpen: boolean | null; | |
| 32 | − | /** On an issue: the pull request g1t is already working on for it, if any. */ | |
| 33 | − | workingPull: number | null; | |
| 34 | − | /** Set when the comment is on a pull request. */ | |
| 35 | − | pull: { | |
| 36 | − | id: string; | |
| 37 | − | status: PullStatus; | |
| 38 | − | /** Made by g1t, which sees it through. */ | |
| 39 | − | agentAuthored: boolean; | |
| 40 | − | /** Where its change is: its fork, or the repository itself. */ | |
| 41 | − | source: RepoPath; | |
| 42 | − | /** The head is a branch of the repository itself, not a fork. */ | |
| 43 | − | inRepo: boolean; | |
| 44 | − | branch: string | null; | |
| 45 | − | headCommit: string | null; | |
| 46 | − | files: string[]; | |
| 47 | − | } | null; | |
| 48 | − | }; | |
| 49 | − | ||
| 50 | − | /** A repository's rules for putting g1t to work by itself. */ | |
| 51 | − | export type AgentRules = { | |
| 52 | − | /** When an issue is given this label, g1t takes it. */ | |
| 53 | − | label: string | null; | |
| 54 | − | updatedBy: string | null; | |
| 55 | − | updatedAt: string | null; | |
| 56 | − | }; | |
| 57 | − | ||
| 58 | − | export interface MentionsApi { | |
| 59 | − | /** For the runner: claims a comment's mention, once. Null when there is none to take. */ | |
| 60 | − | takeMention(commentId: string): Promise<MentionJob | null>; | |
| 61 | − | /** For the runner: sends the author of a g1t pull request back to address the comment. */ | |
| 62 | − | mentionRevision(commentId: string): Promise<Result<LifecycleJob>>; | |
| 63 | − | /** For the runner: says something in the mention's thread as g1t, once. */ | |
| 64 | − | replyMention(commentId: string, body: string): Promise<boolean>; | |
| 65 | − | getAgentRules(repo: RepoPath, viewer: Viewer): Promise<Result<AgentRules>>; | |
| 66 | − | /** Members only. A null or empty label turns the rule off. */ | |
| 67 | − | setAgentRules(actor: User, repo: RepoPath, rules: { label: string | null }): Promise<Result<AgentRules>>; | |
| 68 | − | } | |
| 69 | − | ||
| 70 | − | /** The mention and rule methods of the work service. */ | |
| 71 | − | export function mentionsClient(service: ServiceBinding): MentionsApi { | |
| 72 | − | const call = async <T>(method: string, args: object): Promise<T> => { | |
| 73 | − | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 74 | − | method: "POST", | |
| 75 | − | headers: { "content-type": "application/json" }, | |
| 76 | − | body: JSON.stringify(args), | |
| 77 | − | }); | |
| 78 | − | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 79 | − | return (await response.json()) as T; | |
| 80 | − | }; | |
| 81 | − | return { | |
| 82 | − | takeMention: (commentId) => call("take_mention", { commentId }), | |
| 83 | − | mentionRevision: (commentId) => call("mention_revision", { commentId }), | |
| 84 | − | replyMention: (commentId, body) => call("reply_mention", { commentId, body }), | |
| 85 | − | getAgentRules: (repo, viewer) => call("get_agent_rules", { repo, viewer }), | |
| 86 | − | setAgentRules: (actor, repo, rules) => call("set_agent_rules", { actor, repo, label: rules.label }), | |
| 87 | − | }; | |
| 88 | − | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
A large diff (1,184 lines) is not shown until you ask for it.