@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 +75 -0
- package/package.json +31 -0
- package/src/access.ts +31 -0
- package/src/capability-summary.test.ts +136 -0
- package/src/capability-summary.ts +122 -0
- package/src/context-tools.ts +205 -0
- package/src/docs-tools.ts +53 -0
- package/src/field-diff.test.ts +90 -0
- package/src/field-diff.ts +85 -0
- package/src/index.ts +11 -0
- package/src/integration.ts +47 -0
- package/src/permission.ts +26 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/routes.ts +6 -0
- package/src/rpc-contract.ts +214 -0
- package/src/tool.ts +127 -0
- package/tsconfig.json +11 -0
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,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>;
|