@ithousecall/chat 0.0.0-stage → 0.2.1

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/attachments.d.ts +32 -0
  4. package/dist/attachments.js +159 -0
  5. package/dist/chat/adapter.d.ts +77 -0
  6. package/dist/chat/adapter.js +23 -0
  7. package/dist/chat/console.d.ts +21 -0
  8. package/dist/chat/console.js +105 -0
  9. package/dist/chat/download.d.ts +13 -0
  10. package/dist/chat/download.js +61 -0
  11. package/dist/chat/googlechat.d.ts +54 -0
  12. package/dist/chat/googlechat.js +329 -0
  13. package/dist/chat/http.d.ts +9 -0
  14. package/dist/chat/http.js +21 -0
  15. package/dist/chat/markdown.d.ts +4 -0
  16. package/dist/chat/markdown.js +28 -0
  17. package/dist/chat/slack.d.ts +55 -0
  18. package/dist/chat/slack.js +197 -0
  19. package/dist/chat/teams.d.ts +75 -0
  20. package/dist/chat/teams.js +202 -0
  21. package/dist/config.d.ts +79 -0
  22. package/dist/config.js +155 -0
  23. package/dist/docs/chunk.d.ts +7 -0
  24. package/dist/docs/chunk.js +70 -0
  25. package/dist/docs/confluence.d.ts +12 -0
  26. package/dist/docs/confluence.js +68 -0
  27. package/dist/docs/drive.d.ts +20 -0
  28. package/dist/docs/drive.js +85 -0
  29. package/dist/docs/html.d.ts +2 -0
  30. package/dist/docs/html.js +13 -0
  31. package/dist/docs/index.d.ts +31 -0
  32. package/dist/docs/index.js +146 -0
  33. package/dist/docs/local.d.ts +3 -0
  34. package/dist/docs/local.js +39 -0
  35. package/dist/docs/source.d.ts +14 -0
  36. package/dist/docs/source.js +1 -0
  37. package/dist/docs/sync.d.ts +13 -0
  38. package/dist/docs/sync.js +68 -0
  39. package/dist/docs/tools.d.ts +3 -0
  40. package/dist/docs/tools.js +59 -0
  41. package/dist/extensions/approvals.d.ts +26 -0
  42. package/dist/extensions/approvals.js +144 -0
  43. package/dist/extensions/mcp.d.ts +10 -0
  44. package/dist/extensions/mcp.js +127 -0
  45. package/dist/extensions/tool.d.ts +14 -0
  46. package/dist/extensions/tool.js +8 -0
  47. package/dist/format.d.ts +7 -0
  48. package/dist/format.js +36 -0
  49. package/dist/harness.d.ts +4 -0
  50. package/dist/harness.js +14 -0
  51. package/dist/index.d.ts +7 -0
  52. package/dist/index.js +5 -0
  53. package/dist/lock.d.ts +2 -0
  54. package/dist/lock.js +18 -0
  55. package/dist/model.d.ts +14 -0
  56. package/dist/model.js +87 -0
  57. package/dist/prompt.d.ts +2 -0
  58. package/dist/prompt.js +15 -0
  59. package/dist/start.d.ts +13 -0
  60. package/dist/start.js +148 -0
  61. package/dist/threads.d.ts +19 -0
  62. package/dist/threads.js +73 -0
  63. package/dist/turn.d.ts +12 -0
  64. package/dist/turn.js +206 -0
  65. package/package.json +62 -4
@@ -0,0 +1,75 @@
1
+ import type { Hono } from "hono";
2
+ import { type AdapterHandlers, type ApprovalDecision, type ApprovalOutcome, type ApprovalPrompt, type Attachment, type ChatAdapter, type ChatUser, type InboundMessage, type MessageRef, type ThreadRef } from "./adapter.ts";
3
+ /** The fields housecall reads from a Teams message activity. */
4
+ type TeamsMessage = {
5
+ id: string;
6
+ serviceUrl?: string | undefined;
7
+ text?: string | undefined;
8
+ from: {
9
+ id: string;
10
+ name?: string | undefined;
11
+ };
12
+ recipient: {
13
+ id: string;
14
+ };
15
+ conversation: {
16
+ id: string;
17
+ conversationType?: string | undefined;
18
+ };
19
+ entities?: readonly {
20
+ type: string;
21
+ mentioned?: {
22
+ id: string;
23
+ } | undefined;
24
+ text?: string | null | undefined;
25
+ }[] | undefined;
26
+ attachments?: readonly {
27
+ contentType: string;
28
+ contentUrl?: string | undefined;
29
+ content?: unknown;
30
+ name?: string | undefined;
31
+ }[] | undefined;
32
+ };
33
+ /** The fields housecall reads from an Adaptive Card `Action.Execute` invoke. */
34
+ type TeamsCardAction = {
35
+ from: {
36
+ id: string;
37
+ name?: string | undefined;
38
+ };
39
+ value: {
40
+ action: {
41
+ verb?: string | undefined;
42
+ data: unknown;
43
+ };
44
+ };
45
+ };
46
+ export declare function normalizeTeamsMessage(activity: TeamsMessage): InboundMessage;
47
+ export declare function normalizeTeamsCardAction(activity: TeamsCardAction): ApprovalDecision | undefined;
48
+ type TeamsCredentials = {
49
+ clientId: string;
50
+ clientSecret: string;
51
+ tenantId: string;
52
+ };
53
+ /**
54
+ * Microsoft Teams through an Azure Bot; Teams posts to `/api/messages`. Replies go through the default
55
+ * Bot Framework service URL. Teams offers no thread history without Microsoft Graph, so only mentions are seen.
56
+ */
57
+ export declare class TeamsAdapter implements ChatAdapter {
58
+ #private;
59
+ readonly platform = "teams";
60
+ readonly maxMessageLength = 20000;
61
+ constructor(http: Hono, credentials: TeamsCredentials, options?: {
62
+ allowUnauthenticated?: boolean;
63
+ });
64
+ start(handlers: AdapterHandlers): Promise<void>;
65
+ stop(): Promise<void>;
66
+ post(thread: ThreadRef, markdown: string): Promise<MessageRef>;
67
+ update(ref: MessageRef, markdown: string): Promise<void>;
68
+ remove(ref: MessageRef): Promise<void>;
69
+ postApproval(thread: ThreadRef, prompt: ApprovalPrompt): Promise<MessageRef>;
70
+ resolveApprovalMessage(ref: MessageRef, prompt: ApprovalPrompt, outcome: ApprovalOutcome, by?: ChatUser): Promise<void>;
71
+ /** Attachments other than SharePoint uploads are under their activity's service URL, so they get the bot's token. */
72
+ download(attachment: Attachment, signal: AbortSignal): Promise<Uint8Array>;
73
+ fetchThread(): Promise<InboundMessage[]>;
74
+ }
75
+ export {};
@@ -0,0 +1,202 @@
1
+ import { App } from "@microsoft/teams.apps";
2
+ import { z } from "zod";
3
+ import { approvalArgs, decisionText, messageRef, } from "./adapter.js";
4
+ import { fetchCapped } from "./download.js";
5
+ const FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
6
+ const SHAREPOINT = ".sharepoint.com";
7
+ const FileDownloadInfo = z.object({
8
+ downloadUrl: z.url({ protocol: /^https$/, hostname: /\.sharepoint\.com$/ }),
9
+ fileType: z.string().optional(),
10
+ });
11
+ /**
12
+ * Pasted images are `image/*` attachments under the activity's service URL, read with the bot's token. Files uploaded
13
+ * in a personal chat carry a pre-authorized SharePoint download URL, read without a token. Files in channels and group
14
+ * chats need Microsoft Graph and are left out.
15
+ */
16
+ function teamsAttachments(activity) {
17
+ let pasted = 0;
18
+ return (activity.attachments ?? []).flatMap((attachment) => {
19
+ if (attachment.contentType === FILE_DOWNLOAD_INFO) {
20
+ const info = FileDownloadInfo.safeParse(attachment.content);
21
+ if (!info.success)
22
+ return [];
23
+ const name = attachment.name ?? "file";
24
+ const type = info.data.fileType ?? name.split(".").at(-1) ?? "";
25
+ return [
26
+ {
27
+ name,
28
+ mimeType: /^(png|jpe?g|gif|webp)$/i.test(type) ? "image/*" : "application/octet-stream",
29
+ source: info.data.downloadUrl,
30
+ },
31
+ ];
32
+ }
33
+ const url = attachment.contentUrl;
34
+ if (!attachment.contentType.startsWith("image/") ||
35
+ url === undefined ||
36
+ !underServiceUrl(url, activity.serviceUrl)) {
37
+ return [];
38
+ }
39
+ pasted++;
40
+ return [{ name: attachment.name ?? `image-${pasted}`, mimeType: attachment.contentType, source: url }];
41
+ });
42
+ }
43
+ function underServiceUrl(url, serviceUrl) {
44
+ if (serviceUrl === undefined || !URL.canParse(url) || !URL.canParse(serviceUrl))
45
+ return false;
46
+ const target = new URL(url);
47
+ const base = new URL(serviceUrl);
48
+ const path = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;
49
+ return target.protocol === "https:" && target.origin === base.origin && target.pathname.startsWith(path);
50
+ }
51
+ const VERB = "housecall_approval";
52
+ const ActionData = z.object({ approvalId: z.string(), decision: z.enum(["approve", "deny"]) });
53
+ function teamsUser(account) {
54
+ return account.name === undefined ? { id: account.id } : { id: account.id, name: account.name };
55
+ }
56
+ export function normalizeTeamsMessage(activity) {
57
+ const botMentions = (activity.entities ?? []).filter((entity) => entity.type === "mention" && entity.mentioned?.id === activity.recipient.id);
58
+ let text = activity.text ?? "";
59
+ for (const mention of botMentions)
60
+ if (mention.text)
61
+ text = text.replaceAll(mention.text, "");
62
+ const [spaceId = activity.conversation.id] = activity.conversation.id.split(";");
63
+ const isChannel = activity.conversation.conversationType === "channel";
64
+ return {
65
+ platform: "teams",
66
+ spaceId,
67
+ threadId: isChannel && !activity.conversation.id.includes(";messageid=")
68
+ ? `${activity.conversation.id};messageid=${activity.id}`
69
+ : activity.conversation.id,
70
+ messageId: activity.id,
71
+ user: teamsUser(activity.from),
72
+ text: text.trim(),
73
+ attachments: teamsAttachments(activity),
74
+ isDirect: activity.conversation.conversationType === "personal",
75
+ mentionsBot: botMentions.length > 0,
76
+ cursor: activity.id,
77
+ };
78
+ }
79
+ export function normalizeTeamsCardAction(activity) {
80
+ if (activity.value.action.verb !== VERB)
81
+ return undefined;
82
+ const data = ActionData.safeParse(activity.value.action.data);
83
+ return data.success ? { ...data.data, user: teamsUser(activity.from) } : undefined;
84
+ }
85
+ function approvalCard(prompt) {
86
+ const action = (title, decision) => ({
87
+ type: "Action.Execute",
88
+ title,
89
+ verb: VERB,
90
+ data: { approvalId: prompt.approvalId, decision },
91
+ });
92
+ return {
93
+ type: "AdaptiveCard",
94
+ version: "1.5",
95
+ body: [
96
+ { type: "TextBlock", text: "Approval needed", weight: "Bolder", size: "Medium" },
97
+ { type: "TextBlock", text: prompt.summary, wrap: true },
98
+ {
99
+ type: "TextBlock",
100
+ text: `${prompt.tool} ${approvalArgs(prompt)}`,
101
+ wrap: true,
102
+ isSubtle: true,
103
+ fontType: "Monospace",
104
+ },
105
+ {
106
+ type: "TextBlock",
107
+ text: `Only ${prompt.requester.name ?? prompt.requester.id} can approve or deny.`,
108
+ wrap: true,
109
+ isSubtle: true,
110
+ },
111
+ ],
112
+ actions: [action("Approve", "approve"), action("Deny", "deny")],
113
+ };
114
+ }
115
+ /** Lets the Teams SDK serve `/api/messages` on housecall's own HTTP server; the SDK still validates every token. */
116
+ class HonoRoutes {
117
+ #http;
118
+ constructor(http) {
119
+ this.#http = http;
120
+ }
121
+ registerRoute(_method, path, handler) {
122
+ this.#http.post(path, async (c) => {
123
+ const body = await c.req.json().catch(() => undefined);
124
+ const response = await handler({ body, headers: Object.fromEntries(c.req.raw.headers) });
125
+ return response.body === undefined
126
+ ? new Response(null, { status: response.status })
127
+ : Response.json(response.body, { status: response.status });
128
+ });
129
+ }
130
+ }
131
+ /**
132
+ * Microsoft Teams through an Azure Bot; Teams posts to `/api/messages`. Replies go through the default
133
+ * Bot Framework service URL. Teams offers no thread history without Microsoft Graph, so only mentions are seen.
134
+ */
135
+ export class TeamsAdapter {
136
+ platform = "teams";
137
+ maxMessageLength = 20_000;
138
+ #app;
139
+ constructor(http, credentials, options = {}) {
140
+ this.#app = new App({
141
+ ...credentials,
142
+ httpServerAdapter: new HonoRoutes(http),
143
+ dangerouslyAllowUnauthenticatedRequests: options.allowUnauthenticated === true,
144
+ activity: { mentions: { stripText: false } },
145
+ });
146
+ }
147
+ async start(handlers) {
148
+ this.#app.on("message", async ({ activity }) => {
149
+ void handlers.onMessage(normalizeTeamsMessage(activity));
150
+ });
151
+ this.#app.on("card.action", async ({ activity }) => {
152
+ const decision = normalizeTeamsCardAction(activity);
153
+ const feedback = decision === undefined ? undefined : await handlers.onApproval(decision);
154
+ return {
155
+ statusCode: 200,
156
+ type: "application/vnd.microsoft.activity.message",
157
+ value: feedback?.text ?? "This button is not one of @housecall's.",
158
+ };
159
+ });
160
+ await this.#app.initialize();
161
+ }
162
+ async stop() { }
163
+ async #send(conversationId, activity) {
164
+ return (await this.#app.send(conversationId, activity)).id;
165
+ }
166
+ async post(thread, markdown) {
167
+ return messageRef(thread, await this.#send(thread.threadId, { type: "message", text: markdown, textFormat: "markdown" }));
168
+ }
169
+ async update(ref, markdown) {
170
+ await this.#send(ref.threadId, { type: "message", id: ref.messageId, text: markdown, textFormat: "markdown" });
171
+ }
172
+ async remove(ref) {
173
+ await this.#app.api.conversations.activities(ref.threadId).delete(ref.messageId);
174
+ }
175
+ async postApproval(thread, prompt) {
176
+ const activity = {
177
+ type: "message",
178
+ attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", content: approvalCard(prompt) }],
179
+ };
180
+ return messageRef(thread, await this.#send(thread.threadId, activity));
181
+ }
182
+ async resolveApprovalMessage(ref, prompt, outcome, by) {
183
+ await this.#send(ref.threadId, {
184
+ type: "message",
185
+ id: ref.messageId,
186
+ text: decisionText(prompt, outcome, by),
187
+ attachments: [],
188
+ });
189
+ }
190
+ /** Attachments other than SharePoint uploads are under their activity's service URL, so they get the bot's token. */
191
+ async download(attachment, signal) {
192
+ if (new URL(attachment.source).hostname.endsWith(SHAREPOINT))
193
+ return fetchCapped(attachment.source, { signal });
194
+ const token = await this.#app.tokenProvider.getAppToken();
195
+ if (token === null)
196
+ throw new Error("Teams gave no bot token");
197
+ return fetchCapped(attachment.source, { headers: { authorization: `Bearer ${token.toString()}` }, signal });
198
+ }
199
+ async fetchThread() {
200
+ return [];
201
+ }
202
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from "zod";
2
+ declare const ConfigSchema: z.ZodObject<{
3
+ model: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ provider: z.ZodLiteral<"openai_compatible">;
5
+ base_url: z.ZodURL;
6
+ id: z.ZodString;
7
+ context_window: z.ZodDefault<z.ZodNumber>;
8
+ max_tokens: z.ZodDefault<z.ZodNumber>;
9
+ reasoning: z.ZodDefault<z.ZodBoolean>;
10
+ images: z.ZodDefault<z.ZodBoolean>;
11
+ api_key_env: z.ZodDefault<z.ZodString>;
12
+ }, z.core.$strict>, z.ZodObject<{
13
+ provider: z.ZodLiteral<"openai">;
14
+ id: z.ZodString;
15
+ }, z.core.$strict>, z.ZodObject<{
16
+ provider: z.ZodLiteral<"anthropic">;
17
+ id: z.ZodString;
18
+ }, z.core.$strict>], "provider">;
19
+ chat: z.ZodDefault<z.ZodObject<{
20
+ slack: z.ZodDefault<z.ZodObject<{
21
+ enabled: z.ZodDefault<z.ZodBoolean>;
22
+ }, z.core.$strict>>;
23
+ teams: z.ZodDefault<z.ZodObject<{
24
+ enabled: z.ZodDefault<z.ZodBoolean>;
25
+ }, z.core.$strict>>;
26
+ googlechat: z.ZodDefault<z.ZodObject<{
27
+ enabled: z.ZodDefault<z.ZodBoolean>;
28
+ endpoint_url: z.ZodOptional<z.ZodURL>;
29
+ project_number: z.ZodOptional<z.ZodCoercedString<unknown>>;
30
+ }, z.core.$strict>>;
31
+ }, z.core.$strict>>;
32
+ http: z.ZodDefault<z.ZodObject<{
33
+ port: z.ZodDefault<z.ZodNumber>;
34
+ }, z.core.$strict>>;
35
+ docs: z.ZodDefault<z.ZodObject<{
36
+ sync_interval_minutes: z.ZodDefault<z.ZodNumber>;
37
+ sources: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
38
+ type: z.ZodLiteral<"local">;
39
+ name: z.ZodString;
40
+ path: z.ZodString;
41
+ url_base: z.ZodOptional<z.ZodURL>;
42
+ }, z.core.$strict>, z.ZodObject<{
43
+ type: z.ZodLiteral<"google_drive">;
44
+ name: z.ZodString;
45
+ folder_id: z.ZodString;
46
+ }, z.core.$strict>, z.ZodObject<{
47
+ type: z.ZodLiteral<"confluence">;
48
+ name: z.ZodString;
49
+ site: z.ZodURL;
50
+ space_key: z.ZodOptional<z.ZodString>;
51
+ parent_page_id: z.ZodOptional<z.ZodCoercedString<unknown>>;
52
+ }, z.core.$strict>], "type">>>;
53
+ }, z.core.$strict>>;
54
+ mcp: z.ZodDefault<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
55
+ name: z.ZodString;
56
+ command: z.ZodString;
57
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
58
+ env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
59
+ read_only: z.ZodDefault<z.ZodArray<z.ZodString>>;
60
+ }, z.core.$strict>, z.ZodObject<{
61
+ name: z.ZodString;
62
+ url: z.ZodURL;
63
+ headers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
64
+ read_only: z.ZodDefault<z.ZodArray<z.ZodString>>;
65
+ }, z.core.$strict>]>>>;
66
+ approvals: z.ZodDefault<z.ZodObject<{
67
+ timeout_minutes: z.ZodDefault<z.ZodNumber>;
68
+ }, z.core.$strict>>;
69
+ system_prompt: z.ZodOptional<z.ZodString>;
70
+ }, z.core.$strict>;
71
+ export type Config = z.infer<typeof ConfigSchema>;
72
+ export type ModelConfig = Config["model"];
73
+ export type DocsSourceConfig = Config["docs"]["sources"][number];
74
+ export type McpServerConfig = Config["mcp"][number];
75
+ export declare class ConfigError extends Error {
76
+ }
77
+ export declare function parseConfig(text: string, env?: NodeJS.ProcessEnv): Config;
78
+ export declare function loadConfig(path: string): Config;
79
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,155 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { z } from "zod";
5
+ const Name = z.string().regex(/^[a-z0-9_-]+$/, "use lowercase letters, digits, _ or -");
6
+ const McpName = Name.refine((name) => !name.includes("__"), "must not contain __ (tools are named mcp__<name>__<tool>)");
7
+ const ModelSchema = z.discriminatedUnion("provider", [
8
+ z.strictObject({
9
+ provider: z.literal("openai_compatible"),
10
+ base_url: z.url(),
11
+ id: z.string().min(1),
12
+ context_window: z.number().int().positive().default(128_000),
13
+ max_tokens: z.number().int().positive().default(16_384),
14
+ reasoning: z.boolean().default(false),
15
+ images: z.boolean().default(true),
16
+ api_key_env: z.string().min(1).default("LLM_API_KEY"),
17
+ }),
18
+ z.strictObject({ provider: z.literal("openai"), id: z.string().min(1) }),
19
+ z.strictObject({ provider: z.literal("anthropic"), id: z.string().min(1) }),
20
+ ]);
21
+ const Platform = z.strictObject({ enabled: z.boolean().default(false) });
22
+ const DocsSourceSchema = z.discriminatedUnion("type", [
23
+ z.strictObject({
24
+ type: z.literal("local"),
25
+ name: Name,
26
+ path: z.string().min(1),
27
+ url_base: z.url().optional(),
28
+ }),
29
+ z.strictObject({
30
+ type: z.literal("google_drive"),
31
+ name: Name,
32
+ folder_id: z.string().regex(/^[A-Za-z0-9_-]+$/, "a Drive folder ID, from the folder's URL"),
33
+ }),
34
+ z
35
+ .strictObject({
36
+ type: z.literal("confluence"),
37
+ name: Name,
38
+ site: z.url(),
39
+ space_key: z
40
+ .string()
41
+ .regex(/^~?[A-Za-z0-9]+$/, "a space key, like IT")
42
+ .optional(),
43
+ parent_page_id: z.coerce.string().regex(/^\d+$/, "a page ID: the number in the page URL").optional(),
44
+ })
45
+ .refine((source) => source.space_key !== undefined || source.parent_page_id !== undefined, {
46
+ message: "set space_key, parent_page_id, or both",
47
+ }),
48
+ ]);
49
+ const McpSchema = z.union([
50
+ z.strictObject({
51
+ name: McpName,
52
+ command: z.string().min(1),
53
+ args: z.array(z.string()).default([]),
54
+ env: z.record(z.string(), z.string()).default({}),
55
+ read_only: z.array(z.string()).default([]),
56
+ }),
57
+ z.strictObject({
58
+ name: McpName,
59
+ url: z.url(),
60
+ headers: z.record(z.string(), z.string()).default({}),
61
+ read_only: z.array(z.string()).default([]),
62
+ }),
63
+ ]);
64
+ const ConfigSchema = z.strictObject({
65
+ model: ModelSchema,
66
+ chat: z
67
+ .strictObject({
68
+ slack: Platform.default({ enabled: false }),
69
+ teams: Platform.default({ enabled: false }),
70
+ googlechat: z
71
+ .strictObject({
72
+ enabled: z.boolean().default(false),
73
+ endpoint_url: z.url().optional(),
74
+ project_number: z.coerce
75
+ .string()
76
+ .regex(/^\d+$/, "the Google Cloud project number, from the project's dashboard")
77
+ .optional(),
78
+ })
79
+ .refine((chat) => !chat.enabled || (chat.endpoint_url !== undefined && chat.project_number !== undefined), {
80
+ message: "endpoint_url and project_number are required when googlechat is enabled",
81
+ })
82
+ .default({ enabled: false }),
83
+ })
84
+ .default({ slack: { enabled: false }, teams: { enabled: false }, googlechat: { enabled: false } }),
85
+ http: z.strictObject({ port: z.number().int().positive().default(3978) }).default({ port: 3978 }),
86
+ docs: z
87
+ .strictObject({
88
+ sync_interval_minutes: z.number().positive().default(30),
89
+ sources: z.array(DocsSourceSchema).default([]),
90
+ })
91
+ .refine((docs) => new Set(docs.sources.map((source) => source.name)).size === docs.sources.length, {
92
+ message: "docs source names must be unique",
93
+ })
94
+ .default({ sync_interval_minutes: 30, sources: [] }),
95
+ mcp: z
96
+ .array(McpSchema)
97
+ .refine((servers) => new Set(servers.map((server) => server.name)).size === servers.length, {
98
+ message: "mcp server names must be unique",
99
+ })
100
+ .default([]),
101
+ approvals: z.strictObject({ timeout_minutes: z.number().positive().default(60) }).default({ timeout_minutes: 60 }),
102
+ system_prompt: z.string().optional(),
103
+ });
104
+ export class ConfigError extends Error {
105
+ }
106
+ function substitute(value, env, path, missing) {
107
+ if (typeof value === "string") {
108
+ return value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
109
+ const found = env[name];
110
+ if (found === undefined) {
111
+ missing.push(`${path}: environment variable ${name} is not set`);
112
+ return "";
113
+ }
114
+ return found;
115
+ });
116
+ }
117
+ if (Array.isArray(value)) {
118
+ return value.map((item, index) => substitute(item, env, `${path}.${index}`, missing));
119
+ }
120
+ if (value !== null && typeof value === "object") {
121
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
122
+ key,
123
+ substitute(item, env, path === "" ? key : `${path}.${key}`, missing),
124
+ ]));
125
+ }
126
+ return value;
127
+ }
128
+ export function parseConfig(text, env = process.env) {
129
+ const missing = [];
130
+ const raw = substitute(parse(text), env, "", missing);
131
+ if (missing.length > 0)
132
+ throw new ConfigError(missing.join("\n"));
133
+ const result = ConfigSchema.safeParse(raw);
134
+ if (!result.success) {
135
+ throw new ConfigError(result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n"));
136
+ }
137
+ const config = result.data;
138
+ return {
139
+ ...config,
140
+ docs: {
141
+ ...config.docs,
142
+ sources: config.docs.sources.map((source) => source.type === "local" ? { ...source, path: resolve(source.path) } : source),
143
+ },
144
+ };
145
+ }
146
+ export function loadConfig(path) {
147
+ let text;
148
+ try {
149
+ text = readFileSync(path, "utf8");
150
+ }
151
+ catch {
152
+ throw new ConfigError(`${path}: not found`);
153
+ }
154
+ return parseConfig(text);
155
+ }
@@ -0,0 +1,7 @@
1
+ type Chunk = {
2
+ heading: string;
3
+ text: string;
4
+ };
5
+ /** Split Markdown into searchable chunks: one or more per section, each prefixed by its heading path. */
6
+ export declare function chunkMarkdown(title: string, markdown: string, size?: number, overlap?: number): Chunk[];
7
+ export {};
@@ -0,0 +1,70 @@
1
+ const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
2
+ const FENCE = /^\s*(```|~~~)/;
3
+ function sections(title, markdown) {
4
+ const found = [];
5
+ const stack = [];
6
+ let current = { path: [title], lines: [] };
7
+ let fenced = false;
8
+ for (const line of markdown.split(/\r?\n/)) {
9
+ if (FENCE.test(line))
10
+ fenced = !fenced;
11
+ const heading = fenced ? null : HEADING.exec(line);
12
+ if (heading?.[1] === undefined || heading[2] === undefined) {
13
+ current.lines.push(line);
14
+ continue;
15
+ }
16
+ found.push(current);
17
+ stack.length = heading[1].length - 1;
18
+ stack[heading[1].length - 1] = heading[2];
19
+ current = { path: [title, ...stack.filter((part) => part !== "" && part !== title)], lines: [] };
20
+ }
21
+ found.push(current);
22
+ return found;
23
+ }
24
+ function tail(text, size) {
25
+ if (text.length <= size)
26
+ return text;
27
+ const start = text.indexOf(" ", text.length - size);
28
+ return start === -1 ? text.slice(-size) : text.slice(start + 1);
29
+ }
30
+ function pieces(paragraph, size, overlap) {
31
+ const step = size - overlap;
32
+ const out = [];
33
+ for (let start = 0; start < paragraph.length; start += step) {
34
+ out.push(paragraph.slice(start, start + size));
35
+ if (start + size >= paragraph.length)
36
+ break;
37
+ }
38
+ return out;
39
+ }
40
+ /** Split Markdown into searchable chunks: one or more per section, each prefixed by its heading path. */
41
+ export function chunkMarkdown(title, markdown, size = 1200, overlap = 200) {
42
+ const chunks = [];
43
+ for (const section of sections(title, markdown)) {
44
+ const heading = section.path.join(" > ");
45
+ const paragraphs = section.lines
46
+ .join("\n")
47
+ .split(/\n\s*\n/)
48
+ .map((paragraph) => paragraph.trim())
49
+ .filter((paragraph) => paragraph !== "");
50
+ let text = "";
51
+ for (const paragraph of paragraphs) {
52
+ if (paragraph.length > size) {
53
+ if (text !== "")
54
+ chunks.push({ heading, text });
55
+ for (const piece of pieces(paragraph, size, overlap))
56
+ chunks.push({ heading, text: piece });
57
+ text = "";
58
+ continue;
59
+ }
60
+ if (text !== "" && text.length + paragraph.length + 2 > size) {
61
+ chunks.push({ heading, text });
62
+ text = tail(text, overlap);
63
+ }
64
+ text = text === "" ? paragraph : `${text}\n\n${paragraph}`;
65
+ }
66
+ if (text !== "")
67
+ chunks.push({ heading, text });
68
+ }
69
+ return chunks;
70
+ }
@@ -0,0 +1,12 @@
1
+ import type { DocsSource } from "./source.ts";
2
+ type ConfluenceScope = {
3
+ site: string;
4
+ spaceKey?: string;
5
+ parentPageId?: string;
6
+ };
7
+ /** Pages of one space, or of one page and everything below it, through the Confluence Cloud REST API v2. */
8
+ export declare function confluenceSource(name: string, scope: ConfluenceScope, credentials: {
9
+ email: string;
10
+ apiToken: string;
11
+ }, fetchFn?: typeof fetch): DocsSource;
12
+ export {};
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { storageToMarkdown } from "./html.js";
3
+ const Links = z.object({ next: z.string().optional(), base: z.string().optional() }).optional();
4
+ const Spaces = z.object({ results: z.array(z.object({ id: z.string(), key: z.string() })) });
5
+ const Page = z.object({
6
+ id: z.string(),
7
+ title: z.string(),
8
+ parentId: z.string().nullish(),
9
+ version: z.object({ createdAt: z.string() }),
10
+ _links: z.object({ webui: z.string() }),
11
+ });
12
+ const PageWithSpace = Page.extend({ spaceId: z.string() });
13
+ const Pages = z.object({ results: z.array(Page), _links: Links });
14
+ const PageBody = z.object({ body: z.object({ storage: z.object({ value: z.string() }) }) });
15
+ /** Pages of one space, or of one page and everything below it, through the Confluence Cloud REST API v2. */
16
+ export function confluenceSource(name, scope, credentials, fetchFn = fetch) {
17
+ const site = scope.site.replace(/\/+$/, "");
18
+ const authorization = `Basic ${Buffer.from(`${credentials.email}:${credentials.apiToken}`).toString("base64")}`;
19
+ async function get(path, schema, signal) {
20
+ const response = await fetchFn(`${site}${path}`, {
21
+ headers: { authorization, accept: "application/json" },
22
+ signal,
23
+ });
24
+ if (!response.ok)
25
+ throw new Error(`Confluence ${path} answered ${response.status}`);
26
+ return schema.parse(await response.json());
27
+ }
28
+ async function spaceId(signal) {
29
+ if (scope.spaceKey !== undefined) {
30
+ const spaces = await get(`/wiki/api/v2/spaces?keys=${encodeURIComponent(scope.spaceKey)}`, Spaces, signal);
31
+ const space = spaces.results.find((candidate) => candidate.key === scope.spaceKey);
32
+ if (space === undefined)
33
+ throw new Error(`Confluence space ${scope.spaceKey} not found`);
34
+ return space.id;
35
+ }
36
+ if (scope.parentPageId === undefined)
37
+ throw new Error("Confluence scope needs a space key or a parent page");
38
+ return (await get(`/wiki/api/v2/pages/${scope.parentPageId}`, PageWithSpace, signal)).spaceId;
39
+ }
40
+ return {
41
+ name,
42
+ list: async (signal) => {
43
+ const pages = [];
44
+ let base = `${site}/wiki`;
45
+ let next = `/wiki/api/v2/spaces/${await spaceId(signal)}/pages?limit=250&status=current`;
46
+ while (next !== undefined) {
47
+ const page = await get(next, Pages, signal);
48
+ pages.push(...page.results);
49
+ base = page._links?.base ?? base;
50
+ next = page._links?.next;
51
+ }
52
+ const inScope = scope.parentPageId === undefined ? pages : subtree(pages, scope.parentPageId);
53
+ return inScope.map((page) => ({
54
+ id: page.id,
55
+ title: page.title,
56
+ url: `${base}${page._links.webui}`,
57
+ updatedAt: page.version.createdAt,
58
+ }));
59
+ },
60
+ fetch: async (id, signal) => storageToMarkdown((await get(`/wiki/api/v2/pages/${id}?body-format=storage`, PageBody, signal)).body.storage.value),
61
+ };
62
+ }
63
+ /** The root page and every page whose parent chain reaches it. */
64
+ function subtree(pages, rootId) {
65
+ const parents = new Map(pages.map((page) => [page.id, page.parentId ?? undefined]));
66
+ const reaches = (id, depth) => id !== undefined && depth < 1000 && (id === rootId || reaches(parents.get(id), depth + 1));
67
+ return pages.filter((page) => reaches(page.id, 0));
68
+ }