@checkstack/ai-backend 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.
Files changed (106) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/drizzle/0000_productive_jackpot.sql +26 -0
  3. package/drizzle/0001_puzzling_purple_man.sql +26 -0
  4. package/drizzle/0002_sparkling_paper_doll.sql +15 -0
  5. package/drizzle/0003_married_senator_kelly.sql +1 -0
  6. package/drizzle/0004_crazy_miek.sql +2 -0
  7. package/drizzle/0005_tearful_randall_flagg.sql +1 -0
  8. package/drizzle/meta/0000_snapshot.json +232 -0
  9. package/drizzle/meta/0001_snapshot.json +434 -0
  10. package/drizzle/meta/0002_snapshot.json +551 -0
  11. package/drizzle/meta/0003_snapshot.json +557 -0
  12. package/drizzle/meta/0004_snapshot.json +573 -0
  13. package/drizzle/meta/0005_snapshot.json +574 -0
  14. package/drizzle/meta/_journal.json +48 -0
  15. package/drizzle.config.ts +7 -0
  16. package/package.json +42 -0
  17. package/src/agent-runner.test.ts +262 -0
  18. package/src/agent-runner.ts +262 -0
  19. package/src/chat/agent-loop.test.ts +119 -0
  20. package/src/chat/agent-loop.ts +73 -0
  21. package/src/chat/auto-apply.test.ts +237 -0
  22. package/src/chat/chat-handler.ts +111 -0
  23. package/src/chat/chat-service.streamturn.test.ts +417 -0
  24. package/src/chat/chat-service.test.ts +250 -0
  25. package/src/chat/chat-service.ts +923 -0
  26. package/src/chat/classifier-service.ts +64 -0
  27. package/src/chat/classifier.logic.test.ts +92 -0
  28. package/src/chat/classifier.logic.ts +71 -0
  29. package/src/chat/conversation-store.it.test.ts +203 -0
  30. package/src/chat/conversation-store.test.ts +248 -0
  31. package/src/chat/conversation-store.ts +237 -0
  32. package/src/chat/decision.logic.test.ts +45 -0
  33. package/src/chat/decision.logic.ts +54 -0
  34. package/src/chat/llm-provider.test.ts +63 -0
  35. package/src/chat/llm-provider.ts +67 -0
  36. package/src/chat/model-error.logic.test.ts +60 -0
  37. package/src/chat/model-error.logic.ts +65 -0
  38. package/src/chat/normalize-messages.logic.test.ts +101 -0
  39. package/src/chat/normalize-messages.logic.ts +65 -0
  40. package/src/chat/permission-mode.logic.test.ts +70 -0
  41. package/src/chat/permission-mode.logic.ts +45 -0
  42. package/src/chat/read-invoker.ts +72 -0
  43. package/src/chat/replay.test.ts +174 -0
  44. package/src/chat/scrub-content.test.ts +183 -0
  45. package/src/chat/scrub-content.ts +154 -0
  46. package/src/chat/sdk-tools.test.ts +168 -0
  47. package/src/chat/sdk-tools.ts +181 -0
  48. package/src/chat/title-service.test.ts +146 -0
  49. package/src/chat/title-service.ts +111 -0
  50. package/src/chat/title.logic.test.ts +98 -0
  51. package/src/chat/title.logic.ts +102 -0
  52. package/src/extension-points.ts +41 -0
  53. package/src/generated/docs-index.ts +3020 -0
  54. package/src/hardening/handler-authz.test.ts +282 -0
  55. package/src/hardening/no-secret-leak.test.ts +303 -0
  56. package/src/hooks.ts +33 -0
  57. package/src/index.ts +542 -0
  58. package/src/mcp/connection-registry.test.ts +25 -0
  59. package/src/mcp/connection-registry.ts +54 -0
  60. package/src/mcp/mcp-conformance.it.test.ts +128 -0
  61. package/src/mcp/server.test.ts +285 -0
  62. package/src/mcp/server.ts +300 -0
  63. package/src/mcp/tool-invoker.ts +65 -0
  64. package/src/openai-provider.test.ts +64 -0
  65. package/src/openai-provider.ts +146 -0
  66. package/src/projection.test.ts +97 -0
  67. package/src/projection.ts +132 -0
  68. package/src/propose-apply/args-hash.test.ts +26 -0
  69. package/src/propose-apply/args-hash.ts +30 -0
  70. package/src/propose-apply/service.test.ts +423 -0
  71. package/src/propose-apply/service.ts +419 -0
  72. package/src/propose-apply/store.test.ts +136 -0
  73. package/src/propose-apply/store.ts +224 -0
  74. package/src/propose-apply/token.test.ts +52 -0
  75. package/src/propose-apply/token.ts +71 -0
  76. package/src/rate-limit/spend-ledger.it.test.ts +224 -0
  77. package/src/rate-limit/spend-ledger.test.ts +176 -0
  78. package/src/rate-limit/spend-ledger.ts +162 -0
  79. package/src/rate-limit/tool-budget.it.test.ts +173 -0
  80. package/src/rate-limit/tool-budget.test.ts +58 -0
  81. package/src/rate-limit/tool-budget.ts +107 -0
  82. package/src/registry-wiring.test.ts +131 -0
  83. package/src/registry-wiring.ts +68 -0
  84. package/src/resolver.test.ts +156 -0
  85. package/src/resolver.ts +78 -0
  86. package/src/router.test.ts +78 -0
  87. package/src/router.ts +345 -0
  88. package/src/schema.ts +284 -0
  89. package/src/serializer.test.ts +88 -0
  90. package/src/serializer.ts +42 -0
  91. package/src/tool-registry.ts +58 -0
  92. package/src/tools/composite-tools.ts +24 -0
  93. package/src/tools/docs-tools.test.ts +150 -0
  94. package/src/tools/docs-tools.ts +115 -0
  95. package/src/tools/probe-url.test.ts +51 -0
  96. package/src/tools/probe-url.ts +146 -0
  97. package/src/tools/rank-docs.test.ts +153 -0
  98. package/src/tools/rank-docs.ts +209 -0
  99. package/src/tools/script-context-extract.test.ts +93 -0
  100. package/src/tools/script-context-extract.ts +283 -0
  101. package/src/tools/ssrf-guard.test.ts +69 -0
  102. package/src/tools/ssrf-guard.ts +108 -0
  103. package/src/tools/tool-set.e2e.test.ts +64 -0
  104. package/src/user-rpc-client.test.ts +45 -0
  105. package/src/user-rpc-client.ts +60 -0
  106. package/tsconfig.json +26 -0
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { LanguageModel } from "ai";
3
+ import type { AiConversationStore } from "./conversation-store";
4
+ import {
5
+ applyAutoTitle,
6
+ generateConversationTitle,
7
+ type TitleTextGenerator,
8
+ } from "./title-service";
9
+
10
+ /**
11
+ * A stand-in model. The title logic only forwards it to the injected `generate`
12
+ * fake (which ignores it), so a model id string (a valid `LanguageModel`) keeps
13
+ * tests free of a verbose, version-coupled provider mock.
14
+ */
15
+ const model: LanguageModel = "test-model";
16
+
17
+ /** A generate fake that always resolves to `text`. */
18
+ function generates(text: string): TitleTextGenerator {
19
+ return async () => text;
20
+ }
21
+
22
+ /** A generate fake that always rejects (simulates a model error). */
23
+ const failsToGenerate: TitleTextGenerator = async () => {
24
+ throw new Error("model unavailable");
25
+ };
26
+
27
+ /**
28
+ * A minimal store stub that records updateConversation calls. applyAutoTitle
29
+ * only ever calls updateConversation, so we implement just that method and stub
30
+ * the rest as throwing (the repo's other store tests stub partially too).
31
+ */
32
+ function storeStub() {
33
+ const updates: Array<{ id: string; userId: string; title?: string }> = [];
34
+ const notImplemented = () => {
35
+ throw new Error("not implemented in stub");
36
+ };
37
+ const conversations: AiConversationStore = {
38
+ updateConversation: async (args) => {
39
+ updates.push({ id: args.id, userId: args.userId, title: args.title });
40
+ return undefined;
41
+ },
42
+ createConversation: notImplemented,
43
+ listConversations: notImplemented,
44
+ getConversation: notImplemented,
45
+ appendMessage: notImplemented,
46
+ listMessages: notImplemented,
47
+ archiveConversation: notImplemented,
48
+ deleteConversation: notImplemented,
49
+ };
50
+ return { conversations, updates };
51
+ }
52
+
53
+ describe("generateConversationTitle", () => {
54
+ test("sanitizes a quoted/markdown model reply", async () => {
55
+ const title = await generateConversationTitle({
56
+ model,
57
+ firstMessage: "Summarize the open incidents please",
58
+ generate: generates('"**Open incidents summary**"'),
59
+ });
60
+ expect(title).toBe("Open incidents summary");
61
+ });
62
+
63
+ test("falls back to the heuristic on a model error", async () => {
64
+ const title = await generateConversationTitle({
65
+ model,
66
+ firstMessage: " Summarize the open incidents for me today and more ",
67
+ generate: failsToGenerate,
68
+ });
69
+ // First six words of the collapsed first message.
70
+ expect(title).toBe("Summarize the open incidents for me");
71
+ });
72
+
73
+ test("falls back to the heuristic when the model returns nothing usable", async () => {
74
+ const title = await generateConversationTitle({
75
+ model,
76
+ firstMessage: "Draft an automation",
77
+ generate: generates("***"),
78
+ });
79
+ expect(title).toBe("Draft an automation");
80
+ });
81
+ });
82
+
83
+ describe("applyAutoTitle", () => {
84
+ test("persists the generated title for a titleless conversation", async () => {
85
+ const { conversations, updates } = storeStub();
86
+ await applyAutoTitle({
87
+ conversations,
88
+ model,
89
+ conversationId: "c1",
90
+ userId: "u1",
91
+ firstMessage: "Summarize the open incidents",
92
+ generate: generates("Open incidents summary"),
93
+ });
94
+ expect(updates).toHaveLength(1);
95
+ expect(updates[0]).toEqual({
96
+ id: "c1",
97
+ userId: "u1",
98
+ title: "Open incidents summary",
99
+ });
100
+ });
101
+
102
+ test("persists the heuristic title when the model errors", async () => {
103
+ const { conversations, updates } = storeStub();
104
+ await applyAutoTitle({
105
+ conversations,
106
+ model,
107
+ conversationId: "c1",
108
+ userId: "u1",
109
+ firstMessage: "Draft an automation for on-call paging",
110
+ generate: failsToGenerate,
111
+ });
112
+ expect(updates).toHaveLength(1);
113
+ // "on-call" is a single word, so six words includes "paging".
114
+ expect(updates[0]?.title).toBe("Draft an automation for on-call paging");
115
+ });
116
+
117
+ test("skips the persist when the title resolves empty", async () => {
118
+ const { conversations, updates } = storeStub();
119
+ await applyAutoTitle({
120
+ conversations,
121
+ model,
122
+ conversationId: "c1",
123
+ userId: "u1",
124
+ firstMessage: " ", // empty heuristic too
125
+ generate: generates("***"),
126
+ });
127
+ expect(updates).toHaveLength(0);
128
+ });
129
+
130
+ test("never throws when the store update fails", async () => {
131
+ const { conversations } = storeStub();
132
+ conversations.updateConversation = async () => {
133
+ throw new Error("db down");
134
+ };
135
+ await expect(
136
+ applyAutoTitle({
137
+ conversations,
138
+ model,
139
+ conversationId: "c1",
140
+ userId: "u1",
141
+ firstMessage: "hello",
142
+ generate: generates("A title"),
143
+ }),
144
+ ).resolves.toBeUndefined();
145
+ });
146
+ });
@@ -0,0 +1,111 @@
1
+ import { generateText, type LanguageModel } from "ai";
2
+ import { deriveHeuristicTitle, sanitizeGeneratedTitle } from "./title.logic";
3
+ import type { AiConversationStore } from "./conversation-store";
4
+
5
+ /**
6
+ * Prompt for the cheap title-generation call. Kept terse so the model returns a
7
+ * bare title (no quotes/markdown); `sanitizeGeneratedTitle` defends against any
8
+ * decoration regardless.
9
+ */
10
+ const TITLE_SYSTEM_PROMPT =
11
+ "You generate a short title for a chat conversation. Reply with a concise " +
12
+ "title of at most 6 words that summarizes the user's first message. Do not " +
13
+ "use quotes, markdown, or trailing punctuation. Reply with the title only.";
14
+
15
+ /**
16
+ * The model call used to produce a raw title. Defaults to the AI SDK's
17
+ * `generateText` against the turn's already-built language model; injectable so
18
+ * the title logic is unit-testable WITHOUT constructing a provider mock (the
19
+ * `LanguageModelV3` result shape is verbose and version-coupled).
20
+ */
21
+ export type TitleTextGenerator = (args: {
22
+ model: LanguageModel;
23
+ firstMessage: string;
24
+ }) => Promise<string>;
25
+
26
+ /** Default generator: a cheap `generateText` call reusing the turn's model. */
27
+ const defaultGenerateTitleText: TitleTextGenerator = async ({
28
+ model,
29
+ firstMessage,
30
+ }) => {
31
+ const { text } = await generateText({
32
+ model,
33
+ system: TITLE_SYSTEM_PROMPT,
34
+ prompt: firstMessage,
35
+ });
36
+ return text;
37
+ };
38
+
39
+ /**
40
+ * Resolve the title for a new conversation from its first user message.
41
+ *
42
+ * Tries a cheap `generateText` call (reusing the turn's resolved connection +
43
+ * model) and sanitizes the reply. On ANY model/sanitize failure it falls back to
44
+ * a deterministic heuristic derived from the message. Returns an empty string
45
+ * only when even the heuristic is empty (caller then leaves the title unset).
46
+ *
47
+ * This function NEVER throws: it is invoked fire-and-forget from the streamed
48
+ * turn, so a title failure must not surface to the chat response.
49
+ */
50
+ export async function generateConversationTitle({
51
+ model,
52
+ firstMessage,
53
+ generate = defaultGenerateTitleText,
54
+ }: {
55
+ /** The already-built language model used for the turn (provider-agnostic). */
56
+ model: LanguageModel;
57
+ /** The user's first message in the conversation. */
58
+ firstMessage: string;
59
+ /** Override the model call (tests inject a fake; defaults to generateText). */
60
+ generate?: TitleTextGenerator;
61
+ }): Promise<string> {
62
+ const heuristic = deriveHeuristicTitle(firstMessage);
63
+ try {
64
+ const text = await generate({ model, firstMessage });
65
+ const sanitized = sanitizeGeneratedTitle(text);
66
+ return sanitized || heuristic;
67
+ } catch {
68
+ // Fail closed to the heuristic: a title is a nicety, never worth a throw.
69
+ return heuristic;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Generate and PERSIST a title for a still-untitled conversation from its first
75
+ * user message. Designed to run fire-and-forget from the streamed turn: it never
76
+ * throws, and skips the persist when the resolved title is empty (so the sidebar
77
+ * keeps "Untitled chat" rather than storing a blank). Extracted from the chat
78
+ * service so the persist path is unit-testable with a mocked model + store.
79
+ */
80
+ export async function applyAutoTitle({
81
+ conversations,
82
+ model,
83
+ conversationId,
84
+ userId,
85
+ firstMessage,
86
+ generate,
87
+ }: {
88
+ conversations: AiConversationStore;
89
+ model: LanguageModel;
90
+ conversationId: string;
91
+ userId: string;
92
+ firstMessage: string;
93
+ /** Override the model call (tests inject a fake; defaults to generateText). */
94
+ generate?: TitleTextGenerator;
95
+ }): Promise<void> {
96
+ try {
97
+ const title = await generateConversationTitle({
98
+ model,
99
+ firstMessage,
100
+ generate,
101
+ });
102
+ if (!title) return;
103
+ await conversations.updateConversation({
104
+ id: conversationId,
105
+ userId,
106
+ title,
107
+ });
108
+ } catch {
109
+ // Best-effort; the conversation simply stays untitled on failure.
110
+ }
111
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { deriveHeuristicTitle, sanitizeGeneratedTitle } from "./title.logic";
3
+
4
+ describe("deriveHeuristicTitle", () => {
5
+ test("collapses whitespace and trims", () => {
6
+ expect(deriveHeuristicTitle(" hello world ")).toBe("hello world");
7
+ });
8
+
9
+ test("collapses newlines/tabs into single spaces", () => {
10
+ expect(deriveHeuristicTitle("summarize\n\tthe open incidents")).toBe(
11
+ "summarize the open incidents",
12
+ );
13
+ });
14
+
15
+ test("keeps at most six words", () => {
16
+ expect(
17
+ deriveHeuristicTitle("one two three four five six seven eight"),
18
+ ).toBe("one two three four five six");
19
+ });
20
+
21
+ test("caps at sixty characters with no trailing-cut whitespace", () => {
22
+ const long = "word ".repeat(40); // many words, each short
23
+ const result = deriveHeuristicTitle(long);
24
+ expect(result.length).toBeLessThanOrEqual(60);
25
+ expect(result).toBe(result.trimEnd());
26
+ });
27
+
28
+ test("does not append an ellipsis", () => {
29
+ const result = deriveHeuristicTitle("a".repeat(200));
30
+ expect(result.endsWith("...")).toBe(false);
31
+ expect(result.length).toBeLessThanOrEqual(60);
32
+ });
33
+
34
+ test("returns empty string for empty/whitespace input", () => {
35
+ expect(deriveHeuristicTitle("")).toBe("");
36
+ expect(deriveHeuristicTitle(" \n\t ")).toBe("");
37
+ });
38
+ });
39
+
40
+ describe("sanitizeGeneratedTitle", () => {
41
+ test("strips surrounding double quotes", () => {
42
+ expect(sanitizeGeneratedTitle('"Open incidents summary"')).toBe(
43
+ "Open incidents summary",
44
+ );
45
+ });
46
+
47
+ test("strips surrounding single and smart quotes", () => {
48
+ expect(sanitizeGeneratedTitle("'Health check'")).toBe("Health check");
49
+ expect(sanitizeGeneratedTitle("“Anomaly review”")).toBe("Anomaly review");
50
+ });
51
+
52
+ test("strips nested/repeated wrapping quotes", () => {
53
+ expect(sanitizeGeneratedTitle("\"'Nested title'\"")).toBe("Nested title");
54
+ });
55
+
56
+ test("strips leading markdown heading marker", () => {
57
+ expect(sanitizeGeneratedTitle("## Incident triage")).toBe("Incident triage");
58
+ });
59
+
60
+ test("strips leading list markers", () => {
61
+ expect(sanitizeGeneratedTitle("- Draft automation")).toBe(
62
+ "Draft automation",
63
+ );
64
+ expect(sanitizeGeneratedTitle("1. Draft automation")).toBe(
65
+ "Draft automation",
66
+ );
67
+ });
68
+
69
+ test("removes bold/italic/code markers", () => {
70
+ expect(sanitizeGeneratedTitle("**Bold** title")).toBe("Bold title");
71
+ expect(sanitizeGeneratedTitle("`code` title")).toBe("code title");
72
+ });
73
+
74
+ test("drops trailing punctuation", () => {
75
+ expect(sanitizeGeneratedTitle("Summarize incidents.")).toBe(
76
+ "Summarize incidents",
77
+ );
78
+ expect(sanitizeGeneratedTitle("Is it down?!")).toBe("Is it down");
79
+ });
80
+
81
+ test("only keeps the first line of a multi-line reply", () => {
82
+ expect(sanitizeGeneratedTitle("Open incidents\nmore detail here")).toBe(
83
+ "Open incidents",
84
+ );
85
+ });
86
+
87
+ test("caps to six words and sixty chars", () => {
88
+ expect(
89
+ sanitizeGeneratedTitle("alpha beta gamma delta epsilon zeta eta"),
90
+ ).toBe("alpha beta gamma delta epsilon zeta");
91
+ });
92
+
93
+ test("returns empty string when nothing usable remains", () => {
94
+ expect(sanitizeGeneratedTitle("")).toBe("");
95
+ expect(sanitizeGeneratedTitle('""')).toBe("");
96
+ expect(sanitizeGeneratedTitle("***")).toBe("");
97
+ });
98
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pure, DOM-free helpers for auto-titling a new conversation from its first user
3
+ * message. Both functions are total (never throw) so the fire-and-forget title
4
+ * job can always fall back to a deterministic heuristic when the model errors.
5
+ */
6
+
7
+ /** Max words kept in a derived/sanitized title. */
8
+ const MAX_TITLE_WORDS = 6;
9
+ /** Hard character cap for a title (keeps the sidebar tidy). */
10
+ const MAX_TITLE_CHARS = 60;
11
+
12
+ /**
13
+ * Collapse runs of whitespace into single spaces and trim the ends. Shared by
14
+ * both helpers so titles never carry newlines or doubled spaces from the raw
15
+ * model/user text.
16
+ */
17
+ function collapseWhitespace(value: string): string {
18
+ return value.replaceAll(/\s+/g, " ").trim();
19
+ }
20
+
21
+ /**
22
+ * Cap a single-line string to at most `MAX_TITLE_WORDS` words AND
23
+ * `MAX_TITLE_CHARS` characters, trimming any trailing whitespace left after the
24
+ * cut. No ellipsis is appended (the task forbids ellipsis-from-cut weirdness).
25
+ */
26
+ function capLength(value: string): string {
27
+ const words = value.split(" ").slice(0, MAX_TITLE_WORDS).join(" ");
28
+ if (words.length <= MAX_TITLE_CHARS) return words;
29
+ return words.slice(0, MAX_TITLE_CHARS).trimEnd();
30
+ }
31
+
32
+ /**
33
+ * Derive a deterministic fallback title from the first user message: collapse
34
+ * whitespace, take the first ~6 words, cap at ~60 chars. Returns an empty string
35
+ * only for empty/whitespace-only input (the caller leaves the title unset in
36
+ * that case so the sidebar keeps showing "Untitled chat").
37
+ */
38
+ export function deriveHeuristicTitle(firstMessage: string): string {
39
+ const normalized = collapseWhitespace(firstMessage);
40
+ if (!normalized) return "";
41
+ return capLength(normalized);
42
+ }
43
+
44
+ /**
45
+ * Sanitize a model-generated title: strip surrounding quotes, leading markdown
46
+ * heading/list markers and bold/italic markers, collapse whitespace, drop
47
+ * trailing punctuation, and cap length. Returns an empty string when nothing
48
+ * usable remains so the caller can fall back to the heuristic.
49
+ */
50
+ export function sanitizeGeneratedTitle(raw: string): string {
51
+ // Take only the first non-empty line of a multi-line model reply BEFORE
52
+ // collapsing whitespace (collapsing would erase the line breaks).
53
+ const firstLine = raw
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .find((line) => line.length > 0);
57
+ let value = collapseWhitespace(firstLine ?? "");
58
+ if (!value) return "";
59
+
60
+ // Strip leading markdown heading (#) and list markers (-, *, +, 1.).
61
+ value = value.replace(/^\s*(?:#{1,6}\s*|[-*+]\s+|\d+\.\s+)/, "");
62
+
63
+ // Strip surrounding matching quotes (straight or smart) repeatedly.
64
+ value = stripWrappingQuotes(value);
65
+
66
+ // Remove markdown emphasis markers anywhere (**, __, *, _, `).
67
+ value = value.replaceAll(/[*_`]+/g, "");
68
+
69
+ // Collapse again after marker removal, then strip trailing punctuation.
70
+ value = collapseWhitespace(value).replace(/[.,!?;:]+$/, "");
71
+ value = value.trim();
72
+ if (!value) return "";
73
+
74
+ return capLength(value);
75
+ }
76
+
77
+ /** Strip one or more layers of matching wrapping quotes from a string. */
78
+ function stripWrappingQuotes(value: string): string {
79
+ const pairs: ReadonlyArray<readonly [string, string]> = [
80
+ ['"', '"'],
81
+ ["'", "'"],
82
+ ["“", "”"],
83
+ ["‘", "’"],
84
+ ["`", "`"],
85
+ ];
86
+ let current = value.trim();
87
+ let changed = true;
88
+ while (changed) {
89
+ changed = false;
90
+ for (const [open, close] of pairs) {
91
+ if (
92
+ current.length >= 2 &&
93
+ current.startsWith(open) &&
94
+ current.endsWith(close)
95
+ ) {
96
+ current = current.slice(open.length, current.length - close.length).trim();
97
+ changed = true;
98
+ }
99
+ }
100
+ }
101
+ return current;
102
+ }
@@ -0,0 +1,41 @@
1
+ import { createExtensionPoint } from "@checkstack/backend-api";
2
+ import type { PluginMetadata } from "@checkstack/common";
3
+ import type { RegisteredAiTool } from "./tool-registry";
4
+ import type { ProjectToolInput } from "./projection";
5
+
6
+ /**
7
+ * Path 1 — hand-authored composite tools (decision 2b).
8
+ *
9
+ * Plugins register coarser/curated tools (e.g. `automation.propose`) here when
10
+ * the model needs a different surface than raw CRUD. The tool name is qualified
11
+ * with the registering plugin id before it reaches the registry.
12
+ */
13
+ export interface AiToolExtensionPoint {
14
+ registerTool<TInput, TOutput>(
15
+ tool: RegisteredAiTool<TInput, TOutput>,
16
+ pluginMetadata: PluginMetadata,
17
+ ): void;
18
+ }
19
+
20
+ export const aiToolExtensionPoint = createExtensionPoint<AiToolExtensionPoint>(
21
+ "ai.toolExtensionPoint",
22
+ );
23
+
24
+ /**
25
+ * Path 2 — opt-in projection of an existing oRPC procedure (decision 2a).
26
+ *
27
+ * The projected tool reads the procedure's access rules and input schema
28
+ * verbatim; nothing is duplicated. `effect` is REQUIRED and never inferred.
29
+ *
30
+ * The owning plugin metadata lives on `input.sourcePluginMetadata` (it must be
31
+ * the SOURCE procedure's plugin so qualified access-rule IDs match what
32
+ * `autoAuthMiddleware` enforces), so `expose` takes no separate metadata arg.
33
+ */
34
+ export interface AiToolProjectionExtensionPoint {
35
+ expose<TInput, TOutput>(input: ProjectToolInput<TInput, TOutput>): void;
36
+ }
37
+
38
+ export const aiToolProjectionExtensionPoint =
39
+ createExtensionPoint<AiToolProjectionExtensionPoint>(
40
+ "ai.toolProjectionExtensionPoint",
41
+ );