@checkstack/ai-common 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,75 @@
1
+ # @checkstack/ai-common
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: AI chat UX: ordered turns, readable diffs, persistent errors, auto-titles, decision acknowledgments, and a smarter topical guard.
8
+
9
+ - Turns render as ordered parts (text / tool-call status / confirm card) in chronological order, with inline tool-error lines and a mid-turn "Thinking..." indicator, instead of one text blob plus a flat tool list. The confirm card and tool-step parts no longer vanish after a turn finishes (hydration seeds once per conversation id via `useInitOnceForKey`, so background refetches are no-ops).
10
+ - Errors persist: in-stream provider errors are lifted into the chat hook's durable error state and shown in a dismissible banner with selectable text and a Copy button (single-line digest, full text on hover); it clears on send / open / new chat. The backend installs an `onError` handler that logs the provider's full HTTP response and returns a readable message, and normalizes the model message history (drop empty rows, merge consecutive same-role rows, strip a leading non-user row) so a single provider hiccup can no longer brick a conversation.
11
+ - Confirm/applied card diffs render as a GitHub-style split diff (line-number gutters, per-line tint, word-level highlighting, an "Expand" pop-out). `computeFieldDiff` recurses into arrays element-wise so a single changed leaf is pinpointed instead of dumping whole serialized arrays.
12
+ - Conversations auto-title after the first user message (cheap `generateText` reusing the turn's model, fire-and-forget, heuristic fallback). "New chat" opens immediately and reuses an empty untitled draft instead of spawning duplicates; "Delete" is a soft archive (`archived_at` on `ai_conversations`, data retained). A clean model picker always renders a `Select` of `[defaultModel, ...availableModels]` de-duplicated.
13
+ - The assistant acknowledges a confirm-card decision (a new `decision` mode -> `streamDecision`) instead of going silent after an apply/decline; the decision note is derived server-side from the stored proposal and is ephemeral.
14
+ - A cheap topical pre-classifier short-circuits off-topic turns with a canned refusal (fail-open, spend recorded). It marks meta/capability/greeting/how-to questions as ON_TOPIC; only clearly unrelated requests (coding help, creative writing, trivia) are refused.
15
+ - The chat agent no longer emits duplicate proposals for one request: propose/auto-apply results carry an explicit model-facing "stop and wait" note, and a per-turn `<tool>:<argsHash>` dedupe short-circuits repeated identical mutating calls.
16
+ - Assistant messages render through the shared `<MarkdownBlock>`: it now parses a SAFE subset of raw HTML (`rehype-raw` + `rehype-sanitize`) so native `<details>`/`<summary>` widgets render, and enables `remark-gfm` so GFM tables, strikethrough, and autolinks render (the assistant often summarizes drafts as tables).
17
+
18
+ State and scale: the archive marker, titles, and permission mode all live in the shared `ai_conversations` table, read identically on every pod; the classifier holds no state and its spend is recorded in the shared `ai_spend` ledger. No new pod-local state.
19
+
20
+ This is a beta minor.
21
+
22
+ - 9dcc848: Add the AI platform: a transport-agnostic tool spine, an OAuth Authorization Server + read-only MCP server, a propose/apply flow with audit log, a streaming in-app chat agent, per-conversation permission modes, per-integration spend caps, and user-scoped tool authorization.
23
+
24
+ Two new packages, `@checkstack/ai-common` (the `AiTool` contract, `read`/`mutate`/`destructive` effect classification, the `ai.*` access rules, the OpenAI-compatible connection shape, and the wire contracts) and `@checkstack/ai-backend` (the tool registry, extension points, principal-to-tool resolver, shared zod-to-JSON-Schema serializer, and all transports). The OpenAI-compatible integration provider registers through the existing integration provider extension point, so its API key is stored in the Secrets Vault and configured in the generic Connections UI.
25
+
26
+ What ships:
27
+
28
+ - Tool spine and extension points: `aiToolExtensionPoint.registerTool` (hand-authored composite tools) and `aiToolProjectionExtensionPoint.expose` (opt-in projections of existing oRPC procedures). Authorization mirrors `autoAuthMiddleware` exactly - a tool is surfaced only when every `requiredAccessRules` entry is satisfied, so a scope-narrowed principal can only ever see fewer tools.
29
+ - OAuth + MCP: Checkstack can act as its own OAuth 2.1 Authorization Server (authorization code + PKCE, consent screen, Dynamic Client Registration) and expose a read-only MCP server over Streamable HTTP at `/api/ai/mcp`. Off by default, enabled by the admin `ai.mcp-oauth` setting. A Bearer OAuth-token branch is added to the auth strategy; token scopes are intersected live with the bound user's access rules on every call. A shared-Postgres rate limiter throttles the DCR endpoint per client IP. `getMcpOAuthSettings` / `setMcpOAuthSettings` contracts added to `@checkstack/auth-common`. A minimal OAuth consent page (`/auth/oauth-consent`) renders the requesting client and scopes.
30
+ - Propose/apply + audit: a transport-agnostic two-step service - `propose` re-checks authz, runs the tool's `dryRun` without mutating, and returns a single-use proposal token (the `proposed` audit row IS the token store, 10-minute TTL, atomic single-use); `apply` re-parses the server-stored payload, re-checks authz, and atomically commits. The `ai_tool_calls` audit table records every call across both transports with a SHA-256 args hash (never raw arguments) and stamps who proposed and who applied. An `ai.toolCalled` event carries metadata only.
31
+ - In-app chat: a server-side, provider-agnostic Vercel AI SDK agent loop (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio, ...). The model provider is built on the backend from the integration credentials, so the API key never leaves the backend. The loop offers only resolver-allowed tools, auto-runs read tools (re-entering the live router as the logged-in user) and routes mutating / destructive tools through propose/apply. Durable conversation persistence (`ai_conversations`, `ai_messages`, owner-scoped RPCs) plus a streaming chat UI with a confirm-card component and per-integration model picker.
32
+ - Per-conversation permission mode (Claude-Code-style approve/auto), a durable `permission_mode` column on `ai_conversations` (default `approve`). `read` always auto-runs in both modes; `mutate` inherits the mode (auto-applies server-side in `auto`, confirm-carded in `approve`); `destructive` ALWAYS requires the human `applyTool` in both modes. Security invariant (structural + tested): the mode is consulted only on the `mutate` branch, so no `(effect, mode)` pair routes a destructive tool to auto-apply.
33
+ - Per-integration LLM spend cap (optional `spendCap` = `tokenBudget` + `windowMinutes`, default OFF). Spend is tracked in a shared-Postgres `ai_spend` ledger; enforcement is a rolling-window SUM run before each turn (HTTP 429 over budget). Per-principal tool rate-limit budgets are a rolling COUNT over `ai_tool_calls`, enforced on both transports. An absent / empty / incomplete `spendCap` is treated as "no cap" rather than rejected.
34
+ - Full tool-call replay: `ai_messages.model_messages` (jsonb) persists the canonical AI-SDK `ResponseMessage[]` per turn and replays them verbatim on the next turn; legacy rows fall back to text-only replay.
35
+ - Enforced no-secret-leak scrubbing: `appendMessage` runs `scrubContent` on every write, redacting credential-shaped keys and high-confidence credential values; a canary regression test asserts injected secrets are stripped. A hardening test suite asserts no secret appears in any AI-surface DTO and that handler-side authz holds when the model misbehaves.
36
+ - Provider correctness: the chat provider uses `@ai-sdk/openai-compatible`'s `chatModel` (plain `/chat/completions`), so OpenAI-compatible gateways (OpenRouter, DeepSeek, Ollama, vLLM) no longer reject turns with `invalid_prompt`; `@ai-sdk/openai` is removed.
37
+
38
+ BREAKING CHANGES:
39
+
40
+ - The `AiTool` contract (`@checkstack/ai-common`) gained a `TRpc` type parameter, and both `dryRun` and `execute` now receive a USER-SCOPED `rpcClient` arg bound to the originating user. Every plugin procedure a tool calls re-enters the live router AS THAT USER, so handler-side authorization (access rules AND per-resource/team scope) is enforced exactly as a direct UI/RPC call - closing a prior privilege-escalation where tools captured a trusted service client at construction. A hand-authored tool MUST resolve its plugin client from this per-call arg and MUST NOT capture a trusted service client at factory scope. Tool factories that previously took `{ rpcClient }` should drop that parameter.
41
+ - `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
42
+
43
+ State and scale: conversations, messages, the audit log, proposal tokens, the rate-limit counter, and the spend ledger all live in shared Postgres, so every pod answers identically and the agent loop is resumable on any pod. The only pod-local state is the live MCP connection registry (bookkeeping, never a source of truth). Cross-pod conversation readback, the spend cap, and the tool budget are verified by env-gated two-pod integration tests.
44
+
45
+ This is a beta minor.
46
+
47
+ - 9dcc848: Plugin-owned AI tools: every domain plugin contributes its own AI tools (chat assistant + automation AI action), and `ai-backend` is platform-only.
48
+
49
+ Every plugin-specific AI tool is owned by the plugin whose domain it acts on, registered via that plugin's own `aiToolExtensionPoint` / `aiToolProjectionExtensionPoint` from its init - the same path an external plugin author uses. `ai-backend` no longer imports or depends on any capability plugin's `*-common`; the dependency direction is strictly plugin -> ai-platform. Pure helpers (`computeFieldDiff`, capability-summary, `ScriptContextKind`) live in `@checkstack/ai-common`.
50
+
51
+ Tools shipped:
52
+
53
+ - Health checks and automations: full CRUD - `healthcheck.propose` / `automation.propose` and `*.update` (`mutate`, deep-validated) and `*.delete` (`destructive`, always confirm-gated). `healthcheck.propose`'s dry-run calls the new deep `validateConfiguration` so propose-time validation matches apply-time. Assertions are validated against the collector's result schema and the canonical operator vocabulary. Capability-catalog tools (`ai.listCapabilities`, `ai.getCapabilitySchema`), script context tools (`ai.getScriptContext`, `ai.testScript`), and notify-subscriber tools (`healthcheck.notifySystemSubscribers` / `...GroupSubscribers`).
54
+ - Catalog: `catalog.createSystem` / `updateSystem` / `createGroup` / `updateGroup` (`mutate`), `catalog.deleteSystem` / `deleteGroup` (`destructive`), membership tools (`mutate`), plus `catalog.listSystems` / `listGroups` read projections.
55
+ - Incident: `incident.create` / `update` / `addUpdate` / `resolve` / `addLink` (`mutate`), `incident.delete` / `removeLink` (`destructive`), and `incident.get` / `incident.list` read projections.
56
+ - Maintenance: `maintenance.create` / `update` / `addUpdate` / `close` / `addLink` (`mutate`), `maintenance.delete` / `removeLink` (`destructive`), and `maintenance.list` / `get` read projections.
57
+ - Read projections for SLO (`slo.listObjectives`), dependency (`dependency.list`), incident (`incident.list`), healthcheck (`healthcheck.status`), and anomaly (`anomaly.explain`), each gated by the source procedure's own access rule and routed as the principal.
58
+ - Documentation grounding: `ai.searchDocs` / `ai.getDoc` over a build-time bundled docs index (BM25-ish ranking), so the assistant grounds how-to answers in Checkstack's own docs offline.
59
+ - URL introspection: `ai.probeUrl`, an SSRF-guarded read tool the assistant uses to inspect a real endpoint before drafting a health check. Update tools compute a before -> after field diff rendered on the confirm card (approve mode) or an "Applied" card (auto mode), so a change is never silent.
60
+
61
+ `ai_analyze` automation action (automation-backend, with an editor connection picker + audited tool calls): runs a bounded AI agent on the run context as the automation's `runAs` service account, so it can never exceed that identity's permissions; destructive tools are never offered; mutating tools auto-apply through the service account's client. Produces an `automation.analysis` artifact downstream actions can branch on. The agent loop is exposed as a headless `aiAgentRunnerRef` service so automation-backend can drive it without depending on ai-backend.
62
+
63
+ `notification.notifyForSubscription` is now callable by user / application principals holding `notification.send` (previously service-only). Every tool routes through the user-scoped client, so handler-side authorization is enforced exactly as a direct UI/RPC action; the resolver gate plus the propose/apply re-check at propose AND apply are the additional authority. A systemic authz regression test asserts every registered tool falls into exactly one safe authorization category.
64
+
65
+ A new `ai_transport` enum value `automation` records the AI action's tool calls in the `ai_tool_calls` audit log. No new durable state beyond that; each tool is a thin, deterministic wrapper over an existing RPC, so every pod behaves identically.
66
+
67
+ This is a beta minor.
68
+
69
+ ### Patch Changes
70
+
71
+ - Updated dependencies [9dcc848]
72
+ - Updated dependencies [9dcc848]
73
+ - Updated dependencies [9dcc848]
74
+ - Updated dependencies [9dcc848]
75
+ - @checkstack/common@0.13.0
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@checkstack/ai-common",
3
+ "version": "0.1.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/common": "0.12.0",
14
+ "@orpc/contract": "^1.14.4",
15
+ "zod": "^4.2.1"
16
+ },
17
+ "devDependencies": {
18
+ "@checkstack/scripts": "0.3.4",
19
+ "@checkstack/tsconfig": "0.0.7",
20
+ "typescript": "^5.7.2"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsgo -b",
24
+ "lint": "bun run lint:code",
25
+ "lint:code": "eslint . --max-warnings 0",
26
+ "test": "bun test"
27
+ },
28
+ "checkstack": {
29
+ "type": "common"
30
+ }
31
+ }
package/src/access.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { access } from "@checkstack/common";
2
+
3
+ /**
4
+ * Access rules for the AI platform plugin.
5
+ *
6
+ * These rule IDs share a single vocabulary with OAuth scopes (Phase 2) and the
7
+ * `autoAuthMiddleware` access-rule checks: a tool that requires `ai.tools-manage`
8
+ * is gated by exactly the same string the middleware enforces, so the surfaced
9
+ * tool set can never widen what the principal could already do in the UI.
10
+ */
11
+ export const aiAccess = {
12
+ /**
13
+ * Use the in-app AI chat (Phase 4). Qualified id: `ai.chat.read`.
14
+ * The `access()` factory only supports `read` / `manage` levels, so the
15
+ * domain is carried by the resource segment.
16
+ */
17
+ chatUse: access("chat", "read", "Use the in-app AI chat"),
18
+ /** Manage AI tool projections + introspect the registered tool set. Qualified id: `ai.tools.manage`. */
19
+ toolsManage: access("tools", "manage", "Manage AI tool projections"),
20
+ /** Manage MCP clients and Dynamic Client Registration settings (Phase 2). Qualified id: `ai.mcp.manage`. */
21
+ mcpManage: access("mcp", "manage", "Manage MCP clients and DCR settings"),
22
+ };
23
+
24
+ /**
25
+ * All AI access rules for registration with the plugin system.
26
+ */
27
+ export const aiAccessRules = [
28
+ aiAccess.chatUse,
29
+ aiAccess.toolsManage,
30
+ aiAccess.mcpManage,
31
+ ];
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { RawCapabilityEntry } from "./capability-summary";
3
+ import {
4
+ applyCapabilitySizeGate,
5
+ summarizeConfigSchema,
6
+ summarizeFieldType,
7
+ CAPABILITY_SUMMARY_ENTRY_CAP,
8
+ } from "./capability-summary";
9
+
10
+ describe("summarizeFieldType (pure)", () => {
11
+ test("maps primitive JSON Schema types", () => {
12
+ expect(summarizeFieldType({ property: { type: "string" } })).toBe("string");
13
+ expect(summarizeFieldType({ property: { type: "boolean" } })).toBe(
14
+ "boolean",
15
+ );
16
+ expect(summarizeFieldType({ property: { type: "number" } })).toBe("number");
17
+ expect(summarizeFieldType({ property: { type: "integer" } })).toBe("number");
18
+ expect(summarizeFieldType({ property: { type: "array" } })).toBe("array");
19
+ expect(summarizeFieldType({ property: { type: "object" } })).toBe("object");
20
+ });
21
+
22
+ test("detects enum and union shapes ahead of the base type", () => {
23
+ expect(
24
+ summarizeFieldType({ property: { type: "string", enum: ["a", "b"] } }),
25
+ ).toBe("enum");
26
+ expect(
27
+ summarizeFieldType({ property: { anyOf: [{ type: "string" }] } }),
28
+ ).toBe("union");
29
+ expect(
30
+ summarizeFieldType({ property: { oneOf: [{ type: "number" }] } }),
31
+ ).toBe("union");
32
+ });
33
+
34
+ test("handles array-typed `type` (nullable) and unknown nodes", () => {
35
+ expect(summarizeFieldType({ property: { type: ["string", "null"] } })).toBe(
36
+ "string|null",
37
+ );
38
+ expect(summarizeFieldType({ property: {} })).toBe("unknown");
39
+ expect(summarizeFieldType({ property: 42 })).toBe("unknown");
40
+ expect(summarizeFieldType({ property: null })).toBe("unknown");
41
+ });
42
+ });
43
+
44
+ describe("summarizeConfigSchema (pure)", () => {
45
+ test("derives name + type + required per property", () => {
46
+ const summary = summarizeConfigSchema({
47
+ configSchema: {
48
+ type: "object",
49
+ properties: {
50
+ url: { type: "string" },
51
+ method: { type: "string", enum: ["GET", "POST"] },
52
+ retries: { type: "integer" },
53
+ insecure: { type: "boolean" },
54
+ },
55
+ required: ["url", "method"],
56
+ },
57
+ });
58
+ expect(summary).toEqual([
59
+ { name: "url", type: "string", required: true },
60
+ { name: "method", type: "enum", required: true },
61
+ { name: "retries", type: "number", required: false },
62
+ { name: "insecure", type: "boolean", required: false },
63
+ ]);
64
+ });
65
+
66
+ test("returns undefined for a schema with no properties", () => {
67
+ expect(
68
+ summarizeConfigSchema({ configSchema: { type: "object" } }),
69
+ ).toBeUndefined();
70
+ expect(
71
+ summarizeConfigSchema({ configSchema: {} }),
72
+ ).toBeUndefined();
73
+ });
74
+
75
+ test("returns undefined for non-object / non-schema input", () => {
76
+ expect(summarizeConfigSchema({ configSchema: null })).toBeUndefined();
77
+ expect(summarizeConfigSchema({ configSchema: "nope" })).toBeUndefined();
78
+ expect(summarizeConfigSchema({ configSchema: 7 })).toBeUndefined();
79
+ });
80
+
81
+ test("an empty properties object yields undefined, not []", () => {
82
+ expect(
83
+ summarizeConfigSchema({
84
+ configSchema: { type: "object", properties: {} },
85
+ }),
86
+ ).toBeUndefined();
87
+ });
88
+ });
89
+
90
+ function makeEntry(id: string): RawCapabilityEntry {
91
+ return {
92
+ id,
93
+ displayName: id,
94
+ role: "collector",
95
+ configSummary: [{ name: "x", type: "string", required: true }],
96
+ };
97
+ }
98
+
99
+ describe("applyCapabilitySizeGate (pure)", () => {
100
+ test("keeps summaries and truncated=false at or below the cap", () => {
101
+ const entries = Array.from({ length: CAPABILITY_SUMMARY_ENTRY_CAP }, (_, i) =>
102
+ makeEntry(`c${i}`),
103
+ );
104
+ const gated = applyCapabilitySizeGate({ entries });
105
+ expect(gated.truncated).toBe(false);
106
+ expect(gated.entries).toHaveLength(CAPABILITY_SUMMARY_ENTRY_CAP);
107
+ expect(gated.entries.every((e) => e.configSummary !== undefined)).toBe(true);
108
+ });
109
+
110
+ test("strips summaries and sets truncated=true above the cap", () => {
111
+ const entries = Array.from(
112
+ { length: CAPABILITY_SUMMARY_ENTRY_CAP + 1 },
113
+ (_, i) => makeEntry(`c${i}`),
114
+ );
115
+ const gated = applyCapabilitySizeGate({ entries });
116
+ expect(gated.truncated).toBe(true);
117
+ // Identity + role survive; only the per-entry summary is dropped.
118
+ expect(gated.entries).toHaveLength(CAPABILITY_SUMMARY_ENTRY_CAP + 1);
119
+ expect(gated.entries.every((e) => e.configSummary === undefined)).toBe(true);
120
+ expect(gated.entries[0].id).toBe("c0");
121
+ expect(gated.entries[0].role).toBe("collector");
122
+ });
123
+
124
+ test("honors a custom entry cap", () => {
125
+ const entries = [makeEntry("a"), makeEntry("b"), makeEntry("c")];
126
+ const gated = applyCapabilitySizeGate({ entries, entryCap: 2 });
127
+ expect(gated.truncated).toBe(true);
128
+ expect(gated.entries.every((e) => e.configSummary === undefined)).toBe(true);
129
+ });
130
+
131
+ test("empty catalog is not truncated", () => {
132
+ const gated = applyCapabilitySizeGate({ entries: [] });
133
+ expect(gated.truncated).toBe(false);
134
+ expect(gated.entries).toEqual([]);
135
+ });
136
+ });
@@ -0,0 +1,122 @@
1
+ import { z } from "zod";
2
+ import type { CapabilityEntry, CapabilityFieldSummary } from "./context-tools";
3
+
4
+ /**
5
+ * The maximum number of entries a `listCapabilities` catalog may carry a
6
+ * per-entry `configSummary` for. Above this the summaries are dropped (the
7
+ * catalog still returns identity + role for every entry) and `truncated` is
8
+ * flagged, so the broad catalog stays small in the model's context window and
9
+ * the model pulls a single kind's FULL schema on demand via
10
+ * `getCapabilitySchema`.
11
+ */
12
+ export const CAPABILITY_SUMMARY_ENTRY_CAP = 12;
13
+
14
+ /**
15
+ * Minimal structural shape of a JSON Schema we read to derive a compact field
16
+ * summary. We never trust the input to be a full draft - we narrow defensively
17
+ * and treat anything we cannot read as "no derivable fields".
18
+ */
19
+ const JsonSchemaShape = z.object({
20
+ type: z.unknown().optional(),
21
+ properties: z.record(z.string(), z.unknown()).optional(),
22
+ required: z.array(z.string()).optional(),
23
+ });
24
+
25
+ const PropertyShape = z.object({
26
+ type: z.unknown().optional(),
27
+ enum: z.array(z.unknown()).optional(),
28
+ format: z.string().optional(),
29
+ items: z.unknown().optional(),
30
+ anyOf: z.array(z.unknown()).optional(),
31
+ oneOf: z.array(z.unknown()).optional(),
32
+ });
33
+
34
+ /**
35
+ * Derive a short, human/model-readable type label from a single JSON Schema
36
+ * property node. Deterministic and dependency-free - it never pulls in a JSON
37
+ * Schema library, so it stays trivially testable.
38
+ */
39
+ export function summarizeFieldType({ property }: { property: unknown }): string {
40
+ const parsed = PropertyShape.safeParse(property);
41
+ if (!parsed.success) return "unknown";
42
+ const node = parsed.data;
43
+
44
+ if (Array.isArray(node.enum) && node.enum.length > 0) return "enum";
45
+ if (Array.isArray(node.anyOf) && node.anyOf.length > 0) return "union";
46
+ if (Array.isArray(node.oneOf) && node.oneOf.length > 0) return "union";
47
+
48
+ const { type } = node;
49
+ if (typeof type === "string") {
50
+ if (type === "array") return "array";
51
+ if (type === "integer") return "number";
52
+ return type;
53
+ }
54
+ // JSON Schema permits `type` to be an array of strings (e.g. ["string", "null"]).
55
+ if (Array.isArray(type)) {
56
+ const labels = type.filter((t): t is string => typeof t === "string");
57
+ if (labels.length > 0) return labels.join("|");
58
+ }
59
+ return "unknown";
60
+ }
61
+
62
+ /**
63
+ * Derive the COMPACT field summary (name + type + required) for one config
64
+ * JSON Schema. Returns `undefined` when the schema has no readable object
65
+ * properties (so callers can omit `configSummary` entirely rather than emit an
66
+ * empty array). Pure and deterministic - this is the function the catalog and
67
+ * its tests pin.
68
+ */
69
+ export function summarizeConfigSchema({
70
+ configSchema,
71
+ }: {
72
+ configSchema: unknown;
73
+ }): CapabilityFieldSummary[] | undefined {
74
+ const parsed = JsonSchemaShape.safeParse(configSchema);
75
+ if (!parsed.success) return undefined;
76
+ const { properties, required } = parsed.data;
77
+ if (!properties) return undefined;
78
+
79
+ const requiredSet = new Set<string>(required);
80
+ const fields: CapabilityFieldSummary[] = Object.keys(properties).map(
81
+ (name) => ({
82
+ name,
83
+ type: summarizeFieldType({ property: properties[name] }),
84
+ required: requiredSet.has(name),
85
+ }),
86
+ );
87
+ if (fields.length === 0) return undefined;
88
+ return fields;
89
+ }
90
+
91
+ /**
92
+ * A catalog entry BEFORE size-gating: it always carries the derived compact
93
+ * summary; the gate decides whether the summary survives into the wire output.
94
+ */
95
+ export interface RawCapabilityEntry extends Omit<CapabilityEntry, "configSummary"> {
96
+ configSummary?: CapabilityFieldSummary[];
97
+ }
98
+
99
+ /**
100
+ * Apply the size gate to a fully-derived catalog. When the catalog has more
101
+ * than {@link CAPABILITY_SUMMARY_ENTRY_CAP} entries, every entry's
102
+ * `configSummary` is stripped (identity/role survive) and `truncated` is set.
103
+ * Otherwise the summaries are kept as-is. Pure - no I/O, deterministic in the
104
+ * input ordering.
105
+ */
106
+ export function applyCapabilitySizeGate({
107
+ entries,
108
+ entryCap = CAPABILITY_SUMMARY_ENTRY_CAP,
109
+ }: {
110
+ entries: RawCapabilityEntry[];
111
+ entryCap?: number;
112
+ }): { entries: CapabilityEntry[]; truncated: boolean } {
113
+ const overCap = entries.length > entryCap;
114
+ const gated: CapabilityEntry[] = entries.map((entry) => {
115
+ if (overCap) {
116
+ const { configSummary: _omit, ...rest } = entry;
117
+ return rest;
118
+ }
119
+ return entry;
120
+ });
121
+ return { entries: gated, truncated: overCap };
122
+ }
@@ -0,0 +1,205 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Context taxonomy + wire schemas for the AI assistant's context tools (the
5
+ * script context tools, §2.1-§2.3, and the capability catalog, §2.4, of the
6
+ * AI-assistant context-tools plan). These are transport-facing zod schemas
7
+ * only; the builders + handlers live in `@checkstack/ai-backend`.
8
+ */
9
+
10
+ /**
11
+ * WHERE a script lives. The available SDK symbols + the test runner differ per
12
+ * value, so the model must name the context before it asks for symbols or runs
13
+ * a draft. The enum carries all four contexts from day one so the wire contract
14
+ * never has to widen (OQ-1).
15
+ */
16
+ export const ScriptContextKindSchema = z.enum([
17
+ "healthcheck-script", // inline TS health-check collector
18
+ "healthcheck-shell", // shell health-check collector
19
+ "automation-action-script", // run_script automation action (TS)
20
+ "automation-action-shell", // run_shell automation action
21
+ ]);
22
+ export type ScriptContextKind = z.infer<typeof ScriptContextKindSchema>;
23
+
24
+ /**
25
+ * WHICH capability catalog the model wants from `ai.listCapabilities` (Phase 3).
26
+ * Distinguishes the two registry substrates the editors read from: health-check
27
+ * strategies/collectors and automation triggers/actions/artifact-types. GitOps
28
+ * kinds are intentionally out of v1 scope (OQ-5). Defined here alongside the
29
+ * script taxonomy per §2.1 so the whole context vocabulary lives in one module.
30
+ */
31
+ export const CapabilityContextKindSchema = z.enum([
32
+ "healthcheck", // strategies + collectors
33
+ "automation", // triggers + actions + artifact types
34
+ ]);
35
+ export type CapabilityContextKind = z.infer<typeof CapabilityContextKindSchema>;
36
+
37
+ // ───────────────────────── ai.getScriptContext (§2.2) ─────────────────────
38
+
39
+ export const GetScriptContextInputSchema = z.object({
40
+ context: ScriptContextKindSchema,
41
+ });
42
+ export type GetScriptContextInput = z.infer<typeof GetScriptContextInputSchema>;
43
+
44
+ /** A single injected `CHECKSTACK_*` env var the shell runner exposes. */
45
+ export const ShellEnvVarSchema = z.object({
46
+ name: z.string(),
47
+ description: z.string(),
48
+ });
49
+ export type ShellEnvVar = z.infer<typeof ShellEnvVarSchema>;
50
+
51
+ export const GetScriptContextOutputSchema = z.object({
52
+ context: ScriptContextKindSchema,
53
+ /** Editor language for this context. */
54
+ language: z.enum(["typescript", "shell"]),
55
+ /** The SDK module the script imports from (TS contexts only). */
56
+ sdkModule: z.string().optional(),
57
+ /** The define-helper name (TS contexts only). */
58
+ helper: z.string().optional(),
59
+ /**
60
+ * The relevant `.d.ts` declarations for THIS context, extracted from the
61
+ * generated SDK editor bundle (the SAME types Monaco mounts). For a TS
62
+ * context this is the context's `declare module` block; for a shell context
63
+ * it is a human-readable list of the injected `CHECKSTACK_*` env vars.
64
+ */
65
+ declarations: z.string(),
66
+ /** Injected shell env vars (shell contexts only). */
67
+ shellEnv: z.array(ShellEnvVarSchema).optional(),
68
+ /** A minimal runnable starter the model can adapt. */
69
+ starterExample: z.string(),
70
+ /** Whether managed npm packages are importable in this context. */
71
+ allowsManagedPackages: z.boolean(),
72
+ });
73
+ export type GetScriptContextOutput = z.infer<
74
+ typeof GetScriptContextOutputSchema
75
+ >;
76
+
77
+ // ───────────────────────── ai.testScript (§2.3) ───────────────────────────
78
+
79
+ export const TestScriptInputSchema = z.object({
80
+ context: ScriptContextKindSchema,
81
+ source: z.string().min(1).max(100_000),
82
+ /** Collector/action config the script reads via context.config / fields. */
83
+ config: z.record(z.string(), z.unknown()).optional(),
84
+ /** Sample runtime context (check/system/environment, or event/subscription). */
85
+ sampleContext: z.record(z.string(), z.unknown()).optional(),
86
+ /** Shell-only: extra env. Never carries real secrets (placeholders only). */
87
+ env: z.record(z.string(), z.string()).optional(),
88
+ /** Bounded; defaults to a short ceiling well under the runner's max. */
89
+ timeoutMs: z.number().int().min(100).max(30_000).default(10_000),
90
+ });
91
+ export type TestScriptInput = z.infer<typeof TestScriptInputSchema>;
92
+
93
+ export const TestScriptOutputSchema = z.object({
94
+ /** The default-export / return value the script produced (masked). */
95
+ result: z.unknown().optional(),
96
+ stdout: z.string(),
97
+ stderr: z.string(),
98
+ exitCode: z.number().int().optional(),
99
+ durationMs: z.number().int().nonnegative(),
100
+ timedOut: z.boolean(),
101
+ error: z.string().optional(),
102
+ /** What the sandbox actually enforced/degraded (surfaced, never silent). */
103
+ sandboxDowngraded: z.boolean(),
104
+ });
105
+ export type TestScriptOutput = z.infer<typeof TestScriptOutputSchema>;
106
+
107
+ // ───────────────────────── ai.listCapabilities (§2.4) ──────────────────────
108
+
109
+ /** The normalized role of a single catalog entry across both registries. */
110
+ export const CapabilityRoleSchema = z.enum([
111
+ "strategy",
112
+ "collector",
113
+ "trigger",
114
+ "action",
115
+ "artifact-type",
116
+ ]);
117
+ export type CapabilityRole = z.infer<typeof CapabilityRoleSchema>;
118
+
119
+ /**
120
+ * A COMPACT description of one config field, derived from the entry's full
121
+ * JSON Schema. This is what `listCapabilities` returns per entry so the broad
122
+ * catalog stays small; the model pulls the FULL schema for a single kind via
123
+ * `getCapabilitySchema` only when it is actually configuring that kind.
124
+ */
125
+ export const CapabilityFieldSummarySchema = z.object({
126
+ name: z.string(),
127
+ /** A short type label derived from the JSON Schema (e.g. "string", "enum"). */
128
+ type: z.string(),
129
+ required: z.boolean(),
130
+ });
131
+ export type CapabilityFieldSummary = z.infer<
132
+ typeof CapabilityFieldSummarySchema
133
+ >;
134
+
135
+ /** A catalog entry, normalized across both registries. */
136
+ export const CapabilityEntrySchema = z.object({
137
+ /** Fully-qualified id (e.g. "healthcheck-http.http", "incident.created"). */
138
+ id: z.string(),
139
+ displayName: z.string(),
140
+ description: z.string().optional(),
141
+ role: CapabilityRoleSchema,
142
+ category: z.string().optional(),
143
+ /**
144
+ * A compact, size-gated summary of the config fields (names + types +
145
+ * required). Omitted when the entry's schema has no derivable object fields.
146
+ */
147
+ configSummary: z.array(CapabilityFieldSummarySchema).optional(),
148
+ });
149
+ export type CapabilityEntry = z.infer<typeof CapabilityEntrySchema>;
150
+
151
+ export const ListCapabilitiesInputSchema = z.object({
152
+ context: CapabilityContextKindSchema,
153
+ });
154
+ export type ListCapabilitiesInput = z.infer<typeof ListCapabilitiesInputSchema>;
155
+
156
+ export const ListCapabilitiesOutputSchema = z.object({
157
+ context: CapabilityContextKindSchema,
158
+ entries: z.array(CapabilityEntrySchema),
159
+ /**
160
+ * True when per-entry `configSummary` was dropped to fit the context budget
161
+ * (the catalog had more than the entry cap). Identity/role data is always
162
+ * returned; the model then pulls a single kind's full schema on demand.
163
+ */
164
+ truncated: z.boolean(),
165
+ });
166
+ export type ListCapabilitiesOutput = z.infer<
167
+ typeof ListCapabilitiesOutputSchema
168
+ >;
169
+
170
+ export const GetCapabilitySchemaInputSchema = z.object({
171
+ context: CapabilityContextKindSchema,
172
+ /** The fully-qualified kind id from a `listCapabilities` entry. */
173
+ kind: z.string().min(1),
174
+ });
175
+ export type GetCapabilitySchemaInput = z.infer<
176
+ typeof GetCapabilitySchemaInputSchema
177
+ >;
178
+
179
+ export const GetCapabilitySchemaOutputSchema = z.object({
180
+ context: CapabilityContextKindSchema,
181
+ id: z.string(),
182
+ displayName: z.string(),
183
+ description: z.string().optional(),
184
+ role: CapabilityRoleSchema,
185
+ /**
186
+ * The FULL config JSON Schema for this one kind - the same schema that powers
187
+ * the UI config form, returned intact (field shapes, types, required, enums).
188
+ */
189
+ configSchema: z.record(z.string(), z.unknown()),
190
+ /**
191
+ * Health-check COLLECTORS only: the result JSON Schema whose top-level fields
192
+ * are the ASSERTABLE fields. Author an assertion's `field` from these (e.g.
193
+ * `statusCode`), not a guessed name. Omitted for non-collector kinds.
194
+ */
195
+ resultSchema: z.record(z.string(), z.unknown()).optional(),
196
+ /**
197
+ * Health-check COLLECTORS only: the valid assertion operators per JSON type
198
+ * (and `jsonpath`). An assertion's `operator` MUST be one of these full words
199
+ * (e.g. `equals`, `greaterThanOrEqual`), never an abbreviation like `eq`.
200
+ */
201
+ assertionOperators: z.record(z.string(), z.array(z.string())).optional(),
202
+ });
203
+ export type GetCapabilitySchemaOutput = z.infer<
204
+ typeof GetCapabilitySchemaOutputSchema
205
+ >;
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Wire contracts for the AI assistant's documentation-grounding tools
5
+ * (`ai.searchDocs` + `ai.getDoc`, plan §2.5). Both tools are `effect: "read"`
6
+ * and gated by `ai.chat.read`: any chat user may read the platform's own public
7
+ * documentation; the docs carry no per-tenant data.
8
+ *
9
+ * The docs themselves are a build-time bundled index in `@checkstack/ai-backend`
10
+ * (plan §3.4) — these schemas live in `-common` so the contract is shared.
11
+ */
12
+
13
+ export const SearchDocsInputSchema = z.object({
14
+ query: z.string().min(1).max(400),
15
+ /** Max ranked hits to return (capped server-side; see size budget §3.4). */
16
+ limit: z.number().int().min(1).max(10).default(5),
17
+ });
18
+ export type SearchDocsInput = z.infer<typeof SearchDocsInputSchema>;
19
+
20
+ /** One ranked doc hit: enough for the model to decide whether to getDoc it. */
21
+ export const DocHitSchema = z.object({
22
+ /** Slug-based address, e.g. "user-guide/concepts/health-checks". */
23
+ slug: z.string(),
24
+ title: z.string(),
25
+ /** Section heading the snippet came from (when the hit is a sub-section). */
26
+ heading: z.string().optional(),
27
+ /** The matching snippet (bounded length), highlighting why it matched. */
28
+ snippet: z.string(),
29
+ /** BM25-ish relevance score (opaque ordering hint). */
30
+ score: z.number(),
31
+ });
32
+ export type DocHit = z.infer<typeof DocHitSchema>;
33
+
34
+ export const SearchDocsOutputSchema = z.object({
35
+ hits: z.array(DocHitSchema),
36
+ });
37
+ export type SearchDocsOutput = z.infer<typeof SearchDocsOutputSchema>;
38
+
39
+ export const GetDocInputSchema = z.object({
40
+ slug: z.string().min(1),
41
+ });
42
+ export type GetDocInput = z.infer<typeof GetDocInputSchema>;
43
+
44
+ export const GetDocOutputSchema = z.object({
45
+ slug: z.string(),
46
+ title: z.string(),
47
+ description: z.string().optional(),
48
+ /** Full page content (markdown, frontmatter stripped), bounded; see §3.4. */
49
+ content: z.string(),
50
+ /** True when content was truncated to the size budget. */
51
+ truncated: z.boolean(),
52
+ });
53
+ export type GetDocOutput = z.infer<typeof GetDocOutputSchema>;
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computeFieldDiff } from "./field-diff";
3
+
4
+ describe("computeFieldDiff", () => {
5
+ test("no change yields an empty diff", () => {
6
+ expect(
7
+ computeFieldDiff({ before: { a: 1, b: "x" }, after: { a: 1, b: "x" } }),
8
+ ).toEqual([]);
9
+ });
10
+
11
+ test("reports a changed scalar with a dotted path", () => {
12
+ const diff = computeFieldDiff({
13
+ before: { intervalSeconds: 60 },
14
+ after: { intervalSeconds: 30 },
15
+ });
16
+ expect(diff).toEqual([{ path: "intervalSeconds", before: 60, after: 30 }]);
17
+ });
18
+
19
+ test("recurses into nested objects with dotted paths", () => {
20
+ const diff = computeFieldDiff({
21
+ before: { config: { url: "https://a", method: "GET" } },
22
+ after: { config: { url: "https://b", method: "GET" } },
23
+ });
24
+ expect(diff).toEqual([
25
+ { path: "config.url", before: "https://a", after: "https://b" },
26
+ ]);
27
+ });
28
+
29
+ test("recurses into arrays element-wise (added element)", () => {
30
+ const diff = computeFieldDiff({
31
+ before: { collectors: [{ id: "a" }] },
32
+ after: { collectors: [{ id: "a" }, { id: "b" }] },
33
+ });
34
+ // The unchanged element [0] produces no row; only the added [1] surfaces.
35
+ expect(diff).toEqual([
36
+ { path: "collectors[1]", before: undefined, after: { id: "b" } },
37
+ ]);
38
+ });
39
+
40
+ test("surfaces a single changed field deep inside an array element", () => {
41
+ const diff = computeFieldDiff({
42
+ before: { collectors: [{ id: "a", config: { script: "old" } }] },
43
+ after: { collectors: [{ id: "a", config: { script: "new" } }] },
44
+ });
45
+ expect(diff).toEqual([
46
+ {
47
+ path: "collectors[0].config.script",
48
+ before: "old",
49
+ after: "new",
50
+ },
51
+ ]);
52
+ });
53
+
54
+ test("an element removed from the array surfaces against undefined", () => {
55
+ const diff = computeFieldDiff({
56
+ before: { tags: ["a", "b"] },
57
+ after: { tags: ["a"] },
58
+ });
59
+ expect(diff).toEqual([
60
+ { path: "tags[1]", before: "b", after: undefined },
61
+ ]);
62
+ });
63
+
64
+ test("a value that changes shape (array <-> scalar) is one diff", () => {
65
+ const diff = computeFieldDiff({
66
+ before: { x: [1, 2] },
67
+ after: { x: "now a string" },
68
+ });
69
+ expect(diff).toEqual([
70
+ { path: "x", before: [1, 2], after: "now a string" },
71
+ ]);
72
+ });
73
+
74
+ test("captures added and removed fields", () => {
75
+ const diff = computeFieldDiff({
76
+ before: { a: 1 },
77
+ after: { b: 2 },
78
+ });
79
+ expect(diff).toEqual([
80
+ { path: "a", before: 1, after: undefined },
81
+ { path: "b", before: undefined, after: 2 },
82
+ ]);
83
+ });
84
+
85
+ test("is order-insensitive for object keys", () => {
86
+ expect(
87
+ computeFieldDiff({ before: { a: 1, b: 2 }, after: { b: 2, a: 1 } }),
88
+ ).toEqual([]);
89
+ });
90
+ });
@@ -0,0 +1,85 @@
1
+ import type { AiFieldDiff } from "./tool";
2
+
3
+ /**
4
+ * Compute a leaf-level before -> after diff between two values, used to show what
5
+ * an UPDATE proposal actually changes. Walks plain objects recursively (dotted
6
+ * paths) AND arrays element-wise (`field[i]` paths), so a single changed item in
7
+ * a list - e.g. one collector's script in `collectors[0].config.script` - is
8
+ * surfaced on its own instead of dumping the whole array as one opaque blob.
9
+ * Scalars (and a value that changes shape, e.g. array <-> object) are compared by
10
+ * value via canonical JSON. Added/removed fields and array elements surface with
11
+ * `before`/`after` set to `undefined`.
12
+ *
13
+ * Pure and total: never throws. Returns an empty array when nothing changed.
14
+ */
15
+ export function computeFieldDiff({
16
+ before,
17
+ after,
18
+ }: {
19
+ before: unknown;
20
+ after: unknown;
21
+ }): AiFieldDiff[] {
22
+ const diffs: AiFieldDiff[] = [];
23
+ walk({ before, after, path: "", diffs });
24
+ return diffs;
25
+ }
26
+
27
+ /** True for a plain object (not null, not an array). */
28
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /** Deep equality via canonical (sorted-key) JSON - sufficient for config bags. */
33
+ function deepEqual(a: unknown, b: unknown): boolean {
34
+ return canonical(a) === canonical(b);
35
+ }
36
+
37
+ function canonical(value: unknown): string {
38
+ if (value === undefined) return "undefined";
39
+ if (!isPlainObject(value)) return JSON.stringify(value) ?? "null";
40
+ const keys = Object.keys(value).toSorted();
41
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
42
+ }
43
+
44
+ function walk({
45
+ before,
46
+ after,
47
+ path,
48
+ diffs,
49
+ }: {
50
+ before: unknown;
51
+ after: unknown;
52
+ path: string;
53
+ diffs: AiFieldDiff[];
54
+ }): void {
55
+ if (isPlainObject(before) && isPlainObject(after)) {
56
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
57
+ for (const key of [...keys].toSorted()) {
58
+ walk({
59
+ before: before[key],
60
+ after: after[key],
61
+ path: path ? `${path}.${key}` : key,
62
+ diffs,
63
+ });
64
+ }
65
+ return;
66
+ }
67
+ // Arrays recurse element-wise so a single changed item (a collector, an
68
+ // assertion) is its own diff row, not the whole serialized list. A length
69
+ // change surfaces the added/removed elements against `undefined`.
70
+ if (Array.isArray(before) && Array.isArray(after)) {
71
+ const length = Math.max(before.length, after.length);
72
+ for (let index = 0; index < length; index += 1) {
73
+ walk({
74
+ before: before[index],
75
+ after: after[index],
76
+ path: `${path}[${index}]`,
77
+ diffs,
78
+ });
79
+ }
80
+ return;
81
+ }
82
+ if (!deepEqual(before, after)) {
83
+ diffs.push({ path: path || "(root)", before, after });
84
+ }
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./access";
2
+ export * from "./docs-tools";
3
+ export * from "./permission";
4
+ export * from "./plugin-metadata";
5
+ export * from "./tool";
6
+ export * from "./context-tools";
7
+ export * from "./field-diff";
8
+ export * from "./capability-summary";
9
+ export * from "./integration";
10
+ export * from "./rpc-contract";
11
+ export * from "./routes";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shape of an OpenAI-compatible integration connection.
3
+ *
4
+ * The runtime zod schema (with `x-secret` on `apiKey` and the `Versioned`
5
+ * wrapper) lives in `@checkstack/ai-backend` because it depends on backend-only
6
+ * helpers (`configString` / `Versioned`). This type is the cross-package
7
+ * contract for the same shape.
8
+ *
9
+ * Model choice is a property of the credential / provider (decision §14.6), so
10
+ * it lives on the connection, not a separate global setting:
11
+ * - `baseUrl` — provider base URL (default `https://api.openai.com/v1`).
12
+ * - `apiKey` — secret API key (`x-secret`, stored in the Secrets Vault).
13
+ * - `defaultModel` — required; used unless a conversation overrides it.
14
+ * - `availableModels` — optional allowlist; when present the chat model picker
15
+ * is constrained to it, otherwise a free-text field is shown (Phase 4).
16
+ * - `spendCap` — OPTIONAL per-integration LLM spend cap (Phase 6). Off unless
17
+ * configured. A token-count budget over a rolling window, enforced server-side
18
+ * and counted across all pods from the shared `ai_spend` ledger.
19
+ */
20
+ export interface OpenAiCompatibleConnection {
21
+ baseUrl: string;
22
+ apiKey: string;
23
+ defaultModel: string;
24
+ availableModels?: string[];
25
+ spendCap?: AiSpendCap;
26
+ }
27
+
28
+ /**
29
+ * Optional per-integration LLM spend cap (Phase 6). Token-count, not USD:
30
+ * deterministic and provider-agnostic (OpenAI / Azure / OpenRouter / Ollama /
31
+ * vLLM all report tokens via the AI SDK; only some have a price table). When set,
32
+ * the chat agent loop refuses a new turn once the principal's token usage against
33
+ * this integration in the trailing `windowMinutes` reaches `tokenBudget`. Absent
34
+ * = no cap.
35
+ */
36
+ export interface AiSpendCap {
37
+ /** Max total tokens (input + output) per principal per window. Must be > 0. */
38
+ tokenBudget: number;
39
+ /** Rolling window length in minutes the budget is measured over. Must be > 0. */
40
+ windowMinutes: number;
41
+ }
42
+
43
+ /** Local provider id; namespaced on registration to `ai.openai-compatible`. */
44
+ export const OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID = "openai-compatible";
45
+
46
+ /** Default OpenAI-compatible base URL. */
47
+ export const OPENAI_COMPATIBLE_DEFAULT_BASE_URL = "https://api.openai.com/v1";
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Per-conversation permission mode (Phase 4), Claude-Code-style. Governs ONLY
5
+ * the `mutate` tool branch; reads and destructive tools are NEVER governed by it
6
+ * (see the gating tiers below).
7
+ *
8
+ * The three gating tiers, keyed on a tool's `effect`:
9
+ *
10
+ * - `read` -> ALWAYS auto-runs, in BOTH modes. Reads are never gated.
11
+ * - `mutate` -> INHERITS the mode. `auto` auto-applies SERVER-SIDE (the model's
12
+ * `propose` is applied immediately under the SAME `isAllowed` re-check + audit
13
+ * the human `applyTool` path uses); `approve` surfaces a confirm card the
14
+ * operator must approve via `applyTool`.
15
+ * - `destructive` -> ALWAYS requires the human `applyTool`, in BOTH modes. The
16
+ * mode is NEVER consulted for destructive tools.
17
+ *
18
+ * SECURITY INVARIANT: destructive tools can never auto-apply. The mode has NO
19
+ * parameter into the destructive apply path - it only governs the `mutate`
20
+ * branch.
21
+ */
22
+ export const AiPermissionModeSchema = z.enum(["approve", "auto"]);
23
+ export type AiPermissionMode = z.infer<typeof AiPermissionModeSchema>;
24
+
25
+ /** Safe-by-default: a new conversation requires human approval for changes. */
26
+ export const DEFAULT_PERMISSION_MODE: AiPermissionMode = "approve";
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the AI platform plugin.
5
+ * Exported from the common package so both backend and frontend can reference it.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "ai",
9
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { createRoutes } from "@checkstack/common";
2
+
3
+ /** Route definitions for the AI platform frontend (Phase 4 chat). */
4
+ export const aiRoutes = createRoutes("ai", {
5
+ chat: "/chat",
6
+ });
@@ -0,0 +1,214 @@
1
+ import { z } from "zod";
2
+ import { createClientDefinition, proc } from "@checkstack/common";
3
+ import { aiAccess } from "./access";
4
+ import { AiPermissionModeSchema } from "./permission";
5
+ import { pluginMetadata } from "./plugin-metadata";
6
+ import { AiToolDescriptorSchema } from "./tool";
7
+
8
+ /**
9
+ * AI platform RPC contract.
10
+ *
11
+ * Phase 1 exposes a single read-only introspection endpoint: `listTools`
12
+ * returns the resolver output for the calling principal (the tools they are
13
+ * allowed to see). Mutating tool flows (propose / apply), conversations, and
14
+ * MCP-client management land in later phases and are intentionally absent here.
15
+ *
16
+ * `listTools` is gated by `ai.tools-manage`. The returned descriptors carry
17
+ * only JSON Schema — never an executor or any `x-secret` value.
18
+ */
19
+ /**
20
+ * A proposal returned by `proposeTool`. The `token` is the opaque
21
+ * `propose:<rowId>.<nonce>` consumed by `applyTool` (single-use, 10-min TTL).
22
+ * The `payload` is the validated, ready-to-apply draft (e.g. an automation
23
+ * definition) the confirm card / editor renders. No secret ever appears here.
24
+ */
25
+ export const AiProposalSchema = z.object({
26
+ token: z.string(),
27
+ summary: z.string(),
28
+ payload: z.unknown(),
29
+ toolCallId: z.string(),
30
+ expiresAt: z.coerce.date(),
31
+ });
32
+ export type AiProposal = z.infer<typeof AiProposalSchema>;
33
+
34
+ /**
35
+ * A selectable AI integration for the chat picker (Phase 4, §14.6). Carries
36
+ * only non-secret model UX metadata — NEVER the apiKey.
37
+ */
38
+ export const AiChatIntegrationSchema = z.object({
39
+ /** Qualified connection id. */
40
+ connectionId: z.string(),
41
+ name: z.string(),
42
+ /** The connection's default model id (the picker defaults to this). */
43
+ defaultModel: z.string(),
44
+ /** Optional allowlist constraining the model picker. */
45
+ availableModels: z.array(z.string()).optional(),
46
+ });
47
+ export type AiChatIntegration = z.infer<typeof AiChatIntegrationSchema>;
48
+
49
+ /** A chat conversation summary (Phase 4). Never carries a secret. */
50
+ export const AiConversationSchema = z.object({
51
+ id: z.string(),
52
+ title: z.string().nullable(),
53
+ integrationId: z.string().nullable(),
54
+ model: z.string().nullable(),
55
+ /**
56
+ * Per-conversation permission mode (Phase 4). Governs the `mutate` tool branch
57
+ * only: `auto` auto-applies mutate proposals server-side; `approve` surfaces a
58
+ * confirm card. Reads always run; destructive always requires human apply.
59
+ */
60
+ permissionMode: AiPermissionModeSchema,
61
+ createdAt: z.coerce.date(),
62
+ updatedAt: z.coerce.date(),
63
+ });
64
+ export type AiConversation = z.infer<typeof AiConversationSchema>;
65
+
66
+ /** A persisted chat message (Phase 4). */
67
+ export const AiMessageSchema = z.object({
68
+ id: z.string(),
69
+ conversationId: z.string(),
70
+ role: z.enum(["system", "user", "assistant", "tool"]),
71
+ content: z.record(z.string(), z.unknown()),
72
+ toolCalls: z.array(z.record(z.string(), z.unknown())).nullable(),
73
+ createdAt: z.coerce.date(),
74
+ });
75
+ export type AiMessage = z.infer<typeof AiMessageSchema>;
76
+
77
+ export const aiContract = {
78
+ listTools: proc({
79
+ operationType: "query",
80
+ userType: "authenticated",
81
+ access: [aiAccess.toolsManage],
82
+ }).output(z.object({ tools: z.array(AiToolDescriptorSchema) })),
83
+
84
+ /**
85
+ * Step 1 of the two-step mutating-tool flow: run the tool's dry-run and
86
+ * return a proposal token. NEVER mutates. Per-tool authorization is enforced
87
+ * by the propose/apply service against the tool's `requiredAccessRules`.
88
+ */
89
+ proposeTool: proc({
90
+ operationType: "mutation",
91
+ userType: "authenticated",
92
+ access: [aiAccess.chatUse],
93
+ })
94
+ .input(
95
+ z.object({
96
+ toolName: z.string(),
97
+ input: z.unknown(),
98
+ }),
99
+ )
100
+ .output(AiProposalSchema),
101
+
102
+ /**
103
+ * Step 2: consume a proposal token and commit. Single-use and atomic — a
104
+ * second apply, an expired token, or a tampered nonce is rejected.
105
+ */
106
+ applyTool: proc({
107
+ operationType: "mutation",
108
+ userType: "authenticated",
109
+ access: [aiAccess.chatUse],
110
+ })
111
+ .input(z.object({ token: z.string() }))
112
+ .output(z.object({ toolCallId: z.string(), result: z.unknown() })),
113
+
114
+ // ─── Phase 4: chat conversation management ──────────────────────────────
115
+ // The streaming turn itself is a raw HTTP handler at /api/ai/chat (SSE);
116
+ // these RPCs manage the durable conversation list/transcript (shared Postgres
117
+ // — continuable from any pod). All are owner-scoped server-side.
118
+
119
+ /**
120
+ * List the AI integrations a chat user may select (§14.6). Gated by
121
+ * `ai.chat.read` (NOT integration-manage), and returns only non-secret model
122
+ * UX metadata so a chat-only user can pick a provider + model.
123
+ */
124
+ listChatIntegrations: proc({
125
+ operationType: "query",
126
+ userType: "authenticated",
127
+ access: [aiAccess.chatUse],
128
+ }).output(z.object({ integrations: z.array(AiChatIntegrationSchema) })),
129
+
130
+ listConversations: proc({
131
+ operationType: "query",
132
+ userType: "authenticated",
133
+ access: [aiAccess.chatUse],
134
+ }).output(z.object({ conversations: z.array(AiConversationSchema) })),
135
+
136
+ createConversation: proc({
137
+ operationType: "mutation",
138
+ userType: "authenticated",
139
+ access: [aiAccess.chatUse],
140
+ })
141
+ .input(
142
+ z.object({
143
+ title: z.string().max(200).optional(),
144
+ integrationId: z.string().optional(),
145
+ model: z.string().optional(),
146
+ permissionMode: AiPermissionModeSchema.optional(),
147
+ }),
148
+ )
149
+ .output(AiConversationSchema),
150
+
151
+ getConversation: proc({
152
+ operationType: "query",
153
+ userType: "authenticated",
154
+ access: [aiAccess.chatUse],
155
+ })
156
+ .input(z.object({ id: z.string() }))
157
+ .output(
158
+ z.object({
159
+ conversation: AiConversationSchema,
160
+ messages: z.array(AiMessageSchema),
161
+ }),
162
+ ),
163
+
164
+ updateConversation: proc({
165
+ operationType: "mutation",
166
+ userType: "authenticated",
167
+ access: [aiAccess.chatUse],
168
+ })
169
+ .input(
170
+ z.object({
171
+ id: z.string(),
172
+ title: z.string().max(200).optional(),
173
+ model: z.string().optional(),
174
+ permissionMode: AiPermissionModeSchema.optional(),
175
+ }),
176
+ )
177
+ .output(AiConversationSchema),
178
+
179
+ /**
180
+ * SOFT-DELETE a conversation: the user-facing "Delete" action ARCHIVES the
181
+ * chat (stamps `archivedAt`) so the row + transcript are retained for later
182
+ * abuse introspection while disappearing from the sidebar. Owner-scoped
183
+ * server-side, gated identically to the other conversation mutations.
184
+ */
185
+ archiveConversation: proc({
186
+ operationType: "mutation",
187
+ userType: "authenticated",
188
+ access: [aiAccess.chatUse],
189
+ })
190
+ .input(z.object({ id: z.string() }))
191
+ .output(z.object({ archived: z.boolean() })),
192
+
193
+ deleteConversation: proc({
194
+ operationType: "mutation",
195
+ userType: "authenticated",
196
+ access: [aiAccess.chatUse],
197
+ })
198
+ .input(z.object({ id: z.string() }))
199
+ .output(z.object({ deleted: z.boolean() })),
200
+ };
201
+
202
+ export type AiContract = typeof aiContract;
203
+
204
+ /**
205
+ * Client definition for typed cross-plugin / frontend access to the AI
206
+ * contract.
207
+ */
208
+ export const aiClientDefinition = createClientDefinition(
209
+ aiContract,
210
+ pluginMetadata,
211
+ );
212
+
213
+ /** Conventional `*Api` alias for frontend `usePluginClient(AiApi)` usage. */
214
+ export const AiApi = aiClientDefinition;
package/src/tool.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Effect classification for an AI tool. REQUIRED on every tool and never
5
+ * inferred from the procedure verb — a `mutation` operationType is not the
6
+ * same as a destructive effect.
7
+ *
8
+ * - `read`: pure read; auto-runs in chat and over MCP.
9
+ * - `mutate`: changes state; gated behind the two-step propose -> apply flow
10
+ * (Phase 3).
11
+ * - `destructive`: irreversible state change; same propose -> apply gate, with
12
+ * stronger confirmation UX.
13
+ */
14
+ export const AiToolEffectSchema = z.enum(["read", "mutate", "destructive"]);
15
+ export type AiToolEffect = z.infer<typeof AiToolEffectSchema>;
16
+
17
+ /**
18
+ * One changed field in a before -> after diff, surfaced on a confirm/applied card
19
+ * so the operator always sees exactly WHAT changed (especially for updates), in
20
+ * both approve and auto modes. `path` is a dotted field path; a `before` of
21
+ * `undefined` means the field was added, an `after` of `undefined` means removed.
22
+ */
23
+ export interface AiFieldDiff {
24
+ path: string;
25
+ before: unknown;
26
+ after: unknown;
27
+ }
28
+
29
+ /**
30
+ * Human-readable preview returned by a tool's `dryRun` and shown on a confirm
31
+ * card (chat) or returned for a follow-up `apply` call (MCP). Phase 3 consumes
32
+ * the `payload`; Phase 1 only defines the shape.
33
+ */
34
+ export interface AiProposalPreview<TPayload = unknown> {
35
+ /** One-line, model/human-facing summary of what `apply` will do. */
36
+ summary: string;
37
+ /** The validated, ready-to-apply payload captured at propose time. */
38
+ payload: TPayload;
39
+ /**
40
+ * Optional before -> after diff for an UPDATE proposal. Surfaced on the confirm
41
+ * card (approve mode) and the applied card (auto mode) so a change is always
42
+ * visible. Omit for a create (the whole payload is new).
43
+ */
44
+ diff?: AiFieldDiff[];
45
+ }
46
+
47
+ /**
48
+ * A transport-agnostic, callable AI tool — the spine of the AI platform.
49
+ *
50
+ * The same descriptor backs both transports: the internal chat agent loop
51
+ * (Phase 4) and the external MCP server (Phase 2/3). Its `input` zod schema is
52
+ * serialized to JSON Schema for both OpenAI function calling and MCP tool defs
53
+ * via the shared `toJsonSchema()` serializer — there is no second serializer.
54
+ *
55
+ * @template TInput - validated tool input
56
+ * @template TOutput - tool result shape
57
+ * @template TPrincipal - the authenticated caller; the backend supplies the
58
+ * concrete `AuthUser` type. Kept generic here so `ai-common` does not depend
59
+ * on `@checkstack/backend-api`.
60
+ */
61
+ export interface AiTool<
62
+ TInput = unknown,
63
+ TOutput = unknown,
64
+ TPrincipal = unknown,
65
+ TRpc = unknown,
66
+ > {
67
+ /** Auto-qualified by plugin id on registration, e.g. "automation.propose". */
68
+ name: string;
69
+ /** Model-facing description (becomes the OpenAI / MCP tool description). */
70
+ description: string;
71
+ /** zod input; serialized to JSON Schema via `toJsonSchema()` for both transports. */
72
+ input: z.ZodType<TInput>;
73
+ /** Optional zod output; documents the tool result shape to the model. */
74
+ output?: z.ZodType<TOutput>;
75
+ /** Effect classification. REQUIRED. Never inferred from the verb. */
76
+ effect: AiToolEffect;
77
+ /**
78
+ * Fully-qualified access-rule IDs the principal must satisfy to see/call
79
+ * this tool. SAME vocabulary as OAuth scopes AND the `autoAuthMiddleware`
80
+ * access-rule IDs (`<pluginId>.<resource>.<level>`).
81
+ */
82
+ requiredAccessRules: string[];
83
+ /**
84
+ * For mutate / destructive tools: optional dry-run used by `propose` (Phase
85
+ * 3). Returns a human-readable summary plus the validated payload to apply.
86
+ * Read tools never define this.
87
+ */
88
+ dryRun?(args: {
89
+ input: TInput;
90
+ principal: TPrincipal;
91
+ /** USER-scoped RPC client (see `execute`); use it for any plugin call. */
92
+ rpcClient: TRpc;
93
+ }): Promise<AiProposalPreview>;
94
+ /**
95
+ * The actual call. For mutate / destructive tools this is only reached via
96
+ * `apply` (Phase 3); read tools call it directly.
97
+ *
98
+ * `rpcClient` is a USER-SCOPED client bound to the ORIGINATING user: any
99
+ * plugin procedure it calls re-enters the live router as that user, so
100
+ * handler-side authorization (access rules AND per-resource/team scoping) is
101
+ * enforced exactly as a direct UI/RPC call. A tool MUST use this client for
102
+ * plugin calls; it must NEVER capture a trusted service client, which would
103
+ * bypass the user's authorization and broaden access.
104
+ */
105
+ execute(args: {
106
+ input: TInput;
107
+ principal: TPrincipal;
108
+ rpcClient: TRpc;
109
+ }): Promise<TOutput>;
110
+ }
111
+
112
+ /**
113
+ * Serialized, transport-facing view of a tool (no executors, no zod). This is
114
+ * what introspection RPCs and the MCP tool list return — it carries the JSON
115
+ * Schema for the input, never any executor closure.
116
+ */
117
+ export const AiToolDescriptorSchema = z.object({
118
+ name: z.string(),
119
+ description: z.string(),
120
+ effect: AiToolEffectSchema,
121
+ /** Input JSON Schema (produced by `toJsonSchema()`). */
122
+ inputSchema: z.record(z.string(), z.unknown()),
123
+ /** Output JSON Schema, when the tool declares an `output`. */
124
+ outputSchema: z.record(z.string(), z.unknown()).optional(),
125
+ requiredAccessRules: z.array(z.string()),
126
+ });
127
+ export type AiToolDescriptor = z.infer<typeof AiToolDescriptorSchema>;
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../common"
9
+ }
10
+ ]
11
+ }