g1t/packages/contracts/src/context.ts
| 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; 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 | /** For the runner: the Context section for an agent starting work in a repository, within `budget` characters. */ |
| 171 | runContext(repoId: string, task: string, budget?: number): Promise<RunContext>; |
| 172 | } |
| 173 | |
| 174 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 175 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 176 | method: "POST", |
| 177 | headers: { "content-type": "application/json" }, |
| 178 | body: JSON.stringify(args), |
| 179 | }); |
| 180 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 181 | return (await response.json()) as T; |
| 182 | } |
| 183 | |
| 184 | export function contextClient(service: ServiceBinding): ContextApi { |
| 185 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); |
| 186 | return { |
| 187 | catalog: (workspace, viewer, filter = {}) => call("catalog", { workspace, viewer, ...filter }), |
| 188 | entity: (workspace, viewer, kind, id) => call("entity", { workspace, viewer, kind, id }), |
| 189 | search: (workspace, viewer, query, options = {}) => call("search", { workspace, viewer, query, ...options }), |
| 190 | scorecards: (workspace, viewer) => call("scorecards", { workspace, viewer }), |
| 191 | backfill: (actor, workspace) => call("backfill", { actor, workspace }), |
| 192 | status: (workspace, viewer) => call("status", { workspace, viewer }), |
| 193 | runContext: (repoId, task, budget) => call("run_context", { repoId, task, budget }), |
| 194 | }; |
| 195 | } |
| 196 | |
| 197 | // --- Memory capture and review (the work service) --------------------------- |
| 198 | |
| 199 | /** Where a captured memory came from. */ |
| 200 | export type CaptureSource = "run" | "review" | "pr" | "doc" | "manual"; |
| 201 | |
| 202 | export type CaptureItem = { |
| 203 | scope: "project" | "workspace"; |
| 204 | /** For a project's memory: its repository's id. */ |
| 205 | repoId?: string | null; |
| 206 | kind?: MemoryKind; |
| 207 | text: string; |
| 208 | confidence?: number | null; |
| 209 | source: CaptureSource; |
| 210 | /** One source: `doc:<repo id>:<path>`, `run:<id>`, `comment:<id>`, `pull:<repo id>#<n>`. */ |
| 211 | reference: string; |
| 212 | evidence?: string | null; |
| 213 | number?: number | null; |
| 214 | runId?: string | null; |
| 215 | }; |
| 216 | |
| 217 | export type Captured = { added: number; merged: number; kept: number; refused: number }; |
| 218 | |
| 219 | export type ReviewDecision = "keep" | "dismiss"; |
| 220 | |
| 221 | export interface MemoryReviewApi { |
| 222 | /** Candidates waiting for review in a workspace, or for one project. Members only. */ |
| 223 | listCandidates(viewer: Viewer, workspace: string, repo?: RepoPath | null): Promise<Result<Memory[]>>; |
| 224 | /** Keep a candidate, edited or as it is, or dismiss it. Members only. */ |
| 225 | reviewMemory( |
| 226 | actor: User, |
| 227 | workspace: string, |
| 228 | id: string, |
| 229 | decision: ReviewDecision, |
| 230 | change?: { text?: string; kind?: MemoryKind }, |
| 231 | ): Promise<Result<Memory>>; |
| 232 | /** For services: candidates from docs and backfills. */ |
| 233 | captureMemories(workspace: string, items: CaptureItem[], by?: string): Promise<Captured>; |
| 234 | /** For services: memories by id, in any status. */ |
| 235 | memoriesById(workspace: string, ids: string[]): Promise<Memory[]>; |
| 236 | /** For services: kept memories with every word of `query`; the caller has checked the viewer may read them. */ |
| 237 | searchMemories(workspace: string, query: string | null, options?: { repoIds?: string[] | null; limit?: number }): Promise<Memory[]>; |
| 238 | /** For services: decision and convention candidates from a repository's last merged pull requests. */ |
| 239 | seedFromPulls(repoId: string, limit?: number): Promise<Captured>; |
| 240 | } |
| 241 | |
| 242 | /** Memory capture and review: methods of the work service. */ |
| 243 | export function memoryReviewClient(service: ServiceBinding): MemoryReviewApi { |
| 244 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); |
| 245 | return { |
| 246 | listCandidates: (viewer, workspace, repo) => call("list_candidates", { viewer, workspace, repo: repo ?? null }), |
| 247 | reviewMemory: (actor, workspace, id, decision, change = {}) => call("review_memory", { actor, workspace, id, decision, ...change }), |
| 248 | captureMemories: (workspace, items, by) => call("capture_memories", { workspace, items, by: by ?? null }), |
| 249 | memoriesById: (workspace, ids) => call("memories_by_id", { workspace, ids }), |
| 250 | searchMemories: (workspace, query, options = {}) => |
| 251 | call("search_memories", { workspace, query, repoIds: options.repoIds ?? null, limit: options.limit ?? 20 }), |
| 252 | seedFromPulls: (repoId, limit) => call("seed_from_pulls", { repoId, limit: limit ?? null }), |
| 253 | }; |
| 254 | } |