@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Limn LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # @ithousecall/chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ @housecall is a self-hosted IT helpdesk agent for Slack, Microsoft Teams and Google Chat. It answers from your IT docs
4
+ (a local folder, Google Drive, Confluence), acts through MCP servers and TypeScript tools, and asks the requester to
5
+ confirm any tool that changes something. It runs as one Node 24 process with its state in SQLite.
6
+
7
+ **To self-host @housecall, start from [it-housecall-chat-agent](https://github.com/IT-Housecall/it-housecall-chat-agent)**:
8
+ create your repository from that template and follow its README. This package is the engine the template installs; you
9
+ don't need to install it yourself. Docs: https://chat.ithousecall.app/docs/quickstart
10
+
11
+ To upgrade a project, run `npm install @ithousecall/chat@latest`, commit `package.json` and `package-lock.json`, and rebuild the
12
+ image.
13
+
14
+ ## API
15
+
16
+ ```ts
17
+ import { start } from "@ithousecall/chat";
18
+ import jira from "./extensions/jira.ts";
19
+
20
+ await start({ config: "config/config.yaml", data: "data", extensions: [jira] });
21
+ ```
22
+
23
+ - `start({ config, data, extensions? })` starts @housecall and returns once it is up; on SIGINT or SIGTERM it shuts down
24
+ and exits the process. `config` and `data` are resolved against the working directory, and so are relative paths
25
+ inside the config file. `--console` on the command line selects the console adapter. If @housecall cannot start, for
26
+ example on a config error or when another process uses `data`, it prints `@housecall cannot start:` with the reason and
27
+ exits with status 1.
28
+ - `defineExtension({ name, tools })` and `defineTool({ name, description, parameters, execute, write?, summary? })`
29
+ define tools. `write: true` makes the requester confirm each call in chat before `execute` runs.
30
+ - `requester(api, context)` returns the chat user whose message led to the tool call.
31
+ - `Type` builds tool parameter schemas.
32
+ - Types: `StartOptions`, `ChatUser`, `Extension`, `AgentTool`, `ToolExecutionApi`, `ToolExecutionResult`.
33
+
34
+ ## Attachments
35
+
36
+ Images people post (Slack files, Teams pasted images and personal-chat uploads, Google Chat uploads) are downloaded
37
+ during the turn and sent to the model with the message: PNG, JPEG, GIF and WebP, at most 10 MB and 25 megapixels
38
+ each, at most 4 per turn. Each is resized with pi-durable's Photon processor to at most 1568 px on its long edge and
39
+ 1 MB, or the model's own image limits if smaller. When @housecall is mentioned in a Slack or Google Chat thread, the
40
+ images its author posted in the thread since @housecall last replied are read too, newest first, within the same 4.
41
+ Images posted by others show as `[image]` in the thread history.
42
+
43
+ Images are stored in the conversation in `<data>/session.sqlite`; nothing else is written to disk. Each request to the
44
+ model carries only the images of the newest message; earlier images are replaced by `[image]`, so later answers rely on
45
+ what the model said about them. A thread's messages are taken in the order they arrive, so a message sent while an
46
+ earlier one's images download waits for it.
47
+
48
+ Other files are named to the model but not read. A skipped or unreadable image gets a note to the model and, unless it
49
+ was over 10 MB or past the fourth, a log line `[attachments] <platform> <file>: <problem>`. A model without image input
50
+ (`model.images: false` for `openai_compatible`, or a catalog model without it) is told how many images it cannot see,
51
+ and nothing is downloaded.
52
+
53
+ ## Development
54
+
55
+ ```sh
56
+ npm ci
57
+ npm run ci # type check, lint, tests
58
+ npm run build # dist/
59
+ ```
60
+
61
+ To run a starter against this checkout, build here and run `npm install ../package` in `chat_agent/starter`.
62
+ Don't commit the starter's `package.json` or lockfile after `npm install ../package`.
@@ -0,0 +1,32 @@
1
+ import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
2
+ import type { ImageLimits, ImageProcessor } from "@earendil-works/pi-durable/tools";
3
+ import type { ChatAdapter, InboundMessage } from "./chat/adapter.ts";
4
+ /** How images are prepared for the model; undefined when the model cannot see them. */
5
+ export type ImageOptions = {
6
+ processor: ImageProcessor;
7
+ limits: ImageLimits;
8
+ };
9
+ /** What earlier thread messages show of their attachments. */
10
+ export declare function attachmentMarks(message: InboundMessage): string;
11
+ type ImageHeader = {
12
+ mimeType: string;
13
+ width: number;
14
+ height: number;
15
+ };
16
+ /** The format and declared size of a PNG, JPEG, GIF or WebP, read from its header without decoding it. */
17
+ export declare function imageHeader(bytes: Uint8Array): ImageHeader | undefined;
18
+ /**
19
+ * The model's view of a message's attachments, and of the images its author posted in `earlier` thread messages, newest
20
+ * first: images downloaded and prepared for the model; a note for every file it does not get.
21
+ */
22
+ export declare function readAttachments(adapter: ChatAdapter, message: InboundMessage, earlier: readonly InboundMessage[], prepare: ImageOptions | undefined, signal: AbortSignal): Promise<{
23
+ notes: string[];
24
+ images: ImageContent[];
25
+ }>;
26
+ export declare function inputContent(text: string, read: {
27
+ notes: string[];
28
+ images: ImageContent[];
29
+ }): string | (TextContent | ImageContent)[];
30
+ /** What a request sends: images before the newest user message become `[image]`, so a thread's images are sent once. */
31
+ export declare function olderImagesHidden(messages: readonly Message[]): Message[];
32
+ export {};
@@ -0,0 +1,159 @@
1
+ import { MAX_ATTACHMENT_BYTES } from "./chat/adapter.js";
2
+ import { TooLarge } from "./chat/download.js";
3
+ const MAX_IMAGES = 4;
4
+ /** Photon decodes the whole bitmap, 4 bytes a pixel, before it shrinks it. */
5
+ const MAX_PIXELS = 25_000_000;
6
+ function isImage(attachment) {
7
+ return attachment.mimeType.startsWith("image/");
8
+ }
9
+ /** What earlier thread messages show of their attachments. */
10
+ export function attachmentMarks(message) {
11
+ return message.attachments
12
+ .map((attachment) => (isImage(attachment) ? " [image]" : ` [file ${attachment.name}]`))
13
+ .join("");
14
+ }
15
+ /** The format and declared size of a PNG, JPEG, GIF or WebP, read from its header without decoding it. */
16
+ export function imageHeader(bytes) {
17
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
+ const ascii = (start, end) => String.fromCharCode(...bytes.subarray(start, end));
19
+ try {
20
+ if (ascii(1, 4) === "PNG")
21
+ return { mimeType: "image/png", width: view.getUint32(16), height: view.getUint32(20) };
22
+ if (ascii(0, 4) === "GIF8") {
23
+ return { mimeType: "image/gif", width: view.getUint16(6, true), height: view.getUint16(8, true) };
24
+ }
25
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") {
26
+ const mimeType = "image/webp";
27
+ switch (ascii(12, 16)) {
28
+ case "VP8 ":
29
+ return { mimeType, width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
30
+ case "VP8L": {
31
+ const bits = view.getUint32(21, true);
32
+ return { mimeType, width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
33
+ }
34
+ case "VP8X":
35
+ return {
36
+ mimeType,
37
+ width: (view.getUint32(24, true) & 0xffffff) + 1,
38
+ height: (view.getUint32(27, true) & 0xffffff) + 1,
39
+ };
40
+ default:
41
+ return undefined;
42
+ }
43
+ }
44
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8)
45
+ return undefined;
46
+ for (let offset = 2; offset < bytes.length;) {
47
+ if (bytes[offset] !== 0xff)
48
+ return undefined;
49
+ const marker = view.getUint8(offset + 1);
50
+ if (marker === 0xff) {
51
+ offset++;
52
+ continue;
53
+ }
54
+ // SOF0 to SOF15, except DHT (C4), JPG (C8) and DAC (CC), which share the range.
55
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
56
+ return { mimeType: "image/jpeg", width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
57
+ }
58
+ offset += 2 + view.getUint16(offset + 2);
59
+ }
60
+ return undefined;
61
+ }
62
+ catch (error) {
63
+ if (error instanceof RangeError)
64
+ return undefined;
65
+ throw error;
66
+ }
67
+ }
68
+ /**
69
+ * The model's view of a message's attachments, and of the images its author posted in `earlier` thread messages, newest
70
+ * first: images downloaded and prepared for the model; a note for every file it does not get.
71
+ */
72
+ export async function readAttachments(adapter, message, earlier, prepare, signal) {
73
+ const notes = [];
74
+ const images = [];
75
+ const posted = earlier
76
+ .filter((other) => other.user.id === message.user.id)
77
+ .reverse()
78
+ .flatMap((other) => other.attachments.filter(isImage));
79
+ const attached = [...message.attachments.filter(isImage), ...posted];
80
+ const log = (file, problem) => console.error(`[attachments] ${message.platform} ${file.name}: ${problem}`);
81
+ for (const file of message.attachments)
82
+ if (!isImage(file))
83
+ notes.push(`(attached file ${file.name} was not read)`);
84
+ if (attached.length === 0)
85
+ return { notes, images };
86
+ if (prepare === undefined) {
87
+ const what = attached.length === 1 ? "an image" : `${attached.length} images`;
88
+ notes.push(`(The user attached ${what} you cannot see. Ask them to describe what it shows if it matters.)`);
89
+ return { notes, images };
90
+ }
91
+ let tried = 0;
92
+ for (const file of attached) {
93
+ const tooLarge = `(attached image ${file.name} was skipped: larger than ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB)`;
94
+ if ((file.size ?? 0) > MAX_ATTACHMENT_BYTES) {
95
+ notes.push(tooLarge);
96
+ continue;
97
+ }
98
+ if (tried++ >= MAX_IMAGES) {
99
+ notes.push(`(attached image ${file.name} was skipped: at most ${MAX_IMAGES} images are read at a time)`);
100
+ continue;
101
+ }
102
+ let bytes;
103
+ try {
104
+ bytes = await adapter.download(file, signal);
105
+ }
106
+ catch (error) {
107
+ if (!(error instanceof TooLarge))
108
+ log(file, String(error));
109
+ notes.push(error instanceof TooLarge ? tooLarge : `(attached image ${file.name} could not be downloaded)`);
110
+ continue;
111
+ }
112
+ const header = imageHeader(bytes);
113
+ if (header === undefined) {
114
+ log(file, "not a PNG, JPEG, GIF or WebP image");
115
+ notes.push(`(attached image ${file.name} could not be read)`);
116
+ continue;
117
+ }
118
+ if (header.width * header.height > MAX_PIXELS) {
119
+ log(file, `${header.width}x${header.height} is over ${MAX_PIXELS} pixels`);
120
+ notes.push(`(attached image ${file.name} was skipped: larger than ${MAX_PIXELS / 1_000_000} megapixels)`);
121
+ continue;
122
+ }
123
+ const prepared = await prepare.processor.prepare(bytes, header.mimeType, prepare.limits).catch(() => undefined);
124
+ if (prepared === undefined) {
125
+ log(file, "could not be decoded");
126
+ notes.push(`(attached image ${file.name} could not be read)`);
127
+ }
128
+ else {
129
+ images.push({ type: "image", data: prepared.data, mimeType: prepared.mimeType });
130
+ }
131
+ }
132
+ return { notes, images };
133
+ }
134
+ export function inputContent(text, read) {
135
+ const full = [text, read.notes.join("\n")].filter((part) => part !== "").join("\n\n");
136
+ if (read.images.length === 0)
137
+ return full;
138
+ return [...(full === "" ? [] : [{ type: "text", text: full }]), ...read.images];
139
+ }
140
+ const hidden = (block) => block.type === "image" ? { type: "text", text: "[image]" } : block;
141
+ /** What a request sends: images before the newest user message become `[image]`, so a thread's images are sent once. */
142
+ export function olderImagesHidden(messages) {
143
+ const newest = messages.findLastIndex((message) => message.role === "user");
144
+ return messages.map((message, index) => {
145
+ if (index >= newest)
146
+ return message;
147
+ switch (message.role) {
148
+ case "user":
149
+ return typeof message.content === "string" ? message : { ...message, content: message.content.map(hidden) };
150
+ case "toolResult":
151
+ return { ...message, content: message.content.map(hidden) };
152
+ case "system":
153
+ case "assistant":
154
+ return message;
155
+ default:
156
+ throw new Error(`unknown message role ${JSON.stringify(message)}`);
157
+ }
158
+ });
159
+ }
@@ -0,0 +1,77 @@
1
+ import type { JsonObject } from "@earendil-works/pi-durable";
2
+ export type Platform = "slack" | "teams" | "googlechat" | "console";
3
+ export type ThreadRef = {
4
+ platform: Platform;
5
+ spaceId: string;
6
+ threadId: string;
7
+ };
8
+ export type MessageRef = ThreadRef & {
9
+ messageId: string;
10
+ };
11
+ export type ChatUser = {
12
+ id: string;
13
+ name?: string;
14
+ };
15
+ /** A file on an inbound message. `source` is what the adapter fetches it from: a URL or an API resource name. */
16
+ export type Attachment = {
17
+ name: string;
18
+ mimeType: string;
19
+ size?: number;
20
+ source: string;
21
+ };
22
+ /** Downloads stop at this many bytes. */
23
+ export declare const MAX_ATTACHMENT_BYTES: number;
24
+ export type InboundMessage = ThreadRef & {
25
+ messageId: string;
26
+ user: ChatUser;
27
+ text: string;
28
+ attachments: Attachment[];
29
+ isDirect: boolean;
30
+ mentionsBot: boolean;
31
+ /** Orders messages within a thread; compared as strings. */
32
+ cursor: string;
33
+ };
34
+ export type ApprovalPrompt = {
35
+ approvalId: string;
36
+ tool: string;
37
+ summary: string;
38
+ args: JsonObject;
39
+ requester: ChatUser;
40
+ };
41
+ export type Decision = "approve" | "deny";
42
+ export type ApprovalOutcome = "approved" | "denied" | "expired";
43
+ export type ApprovalDecision = {
44
+ approvalId: string;
45
+ decision: Decision;
46
+ user: ChatUser;
47
+ };
48
+ /** What the person who clicked is told; `counted` when their click decided the approval. */
49
+ export type ApprovalFeedback = {
50
+ counted: boolean;
51
+ text: string;
52
+ };
53
+ export interface AdapterHandlers {
54
+ onMessage(message: InboundMessage): Promise<void>;
55
+ onApproval(decision: ApprovalDecision): Promise<ApprovalFeedback>;
56
+ }
57
+ export interface ChatAdapter {
58
+ readonly platform: Platform;
59
+ readonly maxMessageLength: number;
60
+ start(handlers: AdapterHandlers): Promise<void>;
61
+ stop(): Promise<void>;
62
+ post(thread: ThreadRef, markdown: string): Promise<MessageRef>;
63
+ update(ref: MessageRef, markdown: string): Promise<void>;
64
+ remove(ref: MessageRef): Promise<void>;
65
+ postApproval(thread: ThreadRef, prompt: ApprovalPrompt): Promise<MessageRef>;
66
+ resolveApprovalMessage(ref: MessageRef, prompt: ApprovalPrompt, outcome: ApprovalOutcome, by?: ChatUser): Promise<void>;
67
+ /**
68
+ * The attachment's bytes, at most MAX_ATTACHMENT_BYTES, in a Uint8Array of their own: the image worker cannot take
69
+ * a Buffer from Node's shared pool.
70
+ */
71
+ download(attachment: Attachment, signal: AbortSignal): Promise<Uint8Array>;
72
+ /** Thread messages after `after`, oldest first; [] where the platform offers no history. */
73
+ fetchThread(thread: ThreadRef, after: string | undefined): Promise<InboundMessage[]>;
74
+ }
75
+ export declare function messageRef(thread: ThreadRef, messageId: string): MessageRef;
76
+ export declare function approvalArgs(prompt: ApprovalPrompt): string;
77
+ export declare function decisionText(prompt: ApprovalPrompt, outcome: ApprovalOutcome, by: ChatUser | undefined): string;
@@ -0,0 +1,23 @@
1
+ /** Downloads stop at this many bytes. */
2
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3
+ export function messageRef(thread, messageId) {
4
+ return { platform: thread.platform, spaceId: thread.spaceId, threadId: thread.threadId, messageId };
5
+ }
6
+ const MAX_APPROVAL_ARGS = 1000;
7
+ export function approvalArgs(prompt) {
8
+ const args = JSON.stringify(prompt.args);
9
+ return args.length > MAX_APPROVAL_ARGS ? `${args.slice(0, MAX_APPROVAL_ARGS)}…` : args;
10
+ }
11
+ export function decisionText(prompt, outcome, by) {
12
+ const who = by?.name ?? by?.id;
13
+ switch (outcome) {
14
+ case "approved":
15
+ return `Approved${who === undefined ? "" : ` by ${who}`}: ${prompt.summary}`;
16
+ case "denied":
17
+ return `Denied${who === undefined ? "" : ` by ${who}`}: ${prompt.summary}`;
18
+ case "expired":
19
+ return `Expired without a decision: ${prompt.summary}`;
20
+ default:
21
+ throw new Error(`unknown approval outcome ${JSON.stringify(outcome)}`);
22
+ }
23
+ }
@@ -0,0 +1,21 @@
1
+ import { type AdapterHandlers, type ApprovalOutcome, type ApprovalPrompt, type Attachment, type ChatAdapter, type ChatUser, type InboundMessage, type MessageRef, type ThreadRef } from "./adapter.ts";
2
+ /**
3
+ * stdin/stdout chat, for trying housecall without a chat platform. One thread; `approve <id>` / `deny <id>` decide, and
4
+ * `/image <path> [message]` attaches a local file.
5
+ */
6
+ export declare class ConsoleAdapter implements ChatAdapter {
7
+ #private;
8
+ readonly platform = "console";
9
+ readonly maxMessageLength = 100000;
10
+ /** Resolves once stdin has closed and every message read from it has been handled. */
11
+ readonly ended: Promise<void>;
12
+ start(handlers: AdapterHandlers): Promise<void>;
13
+ stop(): Promise<void>;
14
+ post(thread: ThreadRef, markdown: string): Promise<MessageRef>;
15
+ update(ref: MessageRef, markdown: string): Promise<void>;
16
+ remove(ref: MessageRef): Promise<void>;
17
+ postApproval(thread: ThreadRef, prompt: ApprovalPrompt): Promise<MessageRef>;
18
+ resolveApprovalMessage(ref: MessageRef, prompt: ApprovalPrompt, outcome: ApprovalOutcome, by?: ChatUser): Promise<void>;
19
+ download(attachment: Attachment, signal: AbortSignal): Promise<Uint8Array>;
20
+ fetchThread(): Promise<InboundMessage[]>;
21
+ }
@@ -0,0 +1,105 @@
1
+ import { open } from "node:fs/promises";
2
+ import { userInfo } from "node:os";
3
+ import { basename } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { approvalArgs, decisionText, messageRef, } from "./adapter.js";
6
+ import { readCapped } from "./download.js";
7
+ const THREAD = { platform: "console", spaceId: "console", threadId: "main" };
8
+ function parseConsoleLine(line, user, messageId) {
9
+ const text = line.trim();
10
+ if (text === "")
11
+ return undefined;
12
+ const decision = /^(approve|deny)\s+(\S+)$/i.exec(text);
13
+ if (decision?.[1] !== undefined && decision[2] !== undefined) {
14
+ return { approvalId: decision[2], decision: decision[1].toLowerCase() === "approve" ? "approve" : "deny", user };
15
+ }
16
+ const image = /^\/image\s+(\S+)\s*(.*)$/.exec(text);
17
+ const path = image?.[1];
18
+ const attachments = path === undefined ? [] : [{ name: basename(path), mimeType: "image/*", source: path }];
19
+ return {
20
+ ...THREAD,
21
+ messageId,
22
+ user,
23
+ text: image === null ? text : (image[2] ?? ""),
24
+ attachments,
25
+ isDirect: true,
26
+ mentionsBot: false,
27
+ cursor: messageId,
28
+ };
29
+ }
30
+ /**
31
+ * stdin/stdout chat, for trying housecall without a chat platform. One thread; `approve <id>` / `deny <id>` decide, and
32
+ * `/image <path> [message]` attaches a local file.
33
+ */
34
+ export class ConsoleAdapter {
35
+ platform = "console";
36
+ maxMessageLength = 100_000;
37
+ #user = { id: "console", name: userInfo().username };
38
+ #shown = new Map();
39
+ #handling = new Set();
40
+ #readline;
41
+ #next = 0;
42
+ #ended = () => undefined;
43
+ /** Resolves once stdin has closed and every message read from it has been handled. */
44
+ ended = new Promise((resolve) => {
45
+ this.#ended = resolve;
46
+ });
47
+ async start(handlers) {
48
+ this.#readline = createInterface({ input: process.stdin, terminal: false });
49
+ this.#readline.on("line", (line) => {
50
+ const parsed = parseConsoleLine(line, this.#user, `${Date.now()}-${this.#next++}`);
51
+ if (parsed === undefined)
52
+ return;
53
+ const handled = ("approvalId" in parsed
54
+ ? handlers.onApproval(parsed).then((feedback) => {
55
+ if (!feedback.counted)
56
+ console.log(feedback.text);
57
+ })
58
+ : handlers.onMessage(parsed)).catch((error) => console.error(error));
59
+ this.#handling.add(handled);
60
+ void handled.finally(() => this.#handling.delete(handled));
61
+ });
62
+ this.#readline.on("close", () => {
63
+ void Promise.all(this.#handling).then(() => this.#ended());
64
+ });
65
+ console.log('@housecall console. Ask a question; answer approval prompts with "approve <id>" or "deny <id>"; attach a file with "/image <path> [message]".');
66
+ }
67
+ async stop() {
68
+ this.#readline?.close();
69
+ }
70
+ #print(ref, text) {
71
+ if (this.#shown.get(ref.messageId) === text)
72
+ return;
73
+ this.#shown.set(ref.messageId, text);
74
+ console.log(`\n@housecall [${ref.messageId}]> ${text.replaceAll("\n", "\n ")}`);
75
+ }
76
+ async post(thread, markdown) {
77
+ const ref = messageRef(thread, `m${this.#next++}`);
78
+ this.#print(ref, markdown);
79
+ return ref;
80
+ }
81
+ async update(ref, markdown) {
82
+ this.#print(ref, markdown);
83
+ }
84
+ async remove(ref) {
85
+ this.#shown.delete(ref.messageId);
86
+ }
87
+ async postApproval(thread, prompt) {
88
+ return this.post(thread, `Approval needed: ${prompt.summary}\n${prompt.tool} ${approvalArgs(prompt)}\nType "approve ${prompt.approvalId}" or "deny ${prompt.approvalId}".`);
89
+ }
90
+ async resolveApprovalMessage(ref, prompt, outcome, by) {
91
+ this.#print(ref, decisionText(prompt, outcome, by));
92
+ }
93
+ async download(attachment, signal) {
94
+ const file = await open(attachment.source);
95
+ try {
96
+ return await readCapped(file.createReadStream({ autoClose: false, signal }));
97
+ }
98
+ finally {
99
+ await file.close();
100
+ }
101
+ }
102
+ async fetchThread() {
103
+ return [];
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ export declare class TooLarge extends Error {
2
+ }
3
+ /** The body, failing once it passes `maxBytes`. */
4
+ export declare function readCapped(body: AsyncIterable<Uint8Array>, maxBytes?: number): Promise<Uint8Array>;
5
+ /**
6
+ * GETs an HTTPS URL into memory, at most `maxBytes`. Redirects are followed by hand so `headers`, which may carry a
7
+ * token, go only to the first URL's origin.
8
+ */
9
+ export declare function fetchCapped(url: string, options: {
10
+ headers?: Record<string, string>;
11
+ signal: AbortSignal;
12
+ maxBytes?: number;
13
+ }): Promise<Uint8Array>;
@@ -0,0 +1,61 @@
1
+ import { MAX_ATTACHMENT_BYTES } from "./adapter.js";
2
+ const MAX_REDIRECTS = 5;
3
+ export class TooLarge extends Error {
4
+ }
5
+ /** The body, failing once it passes `maxBytes`. */
6
+ export async function readCapped(body, maxBytes = MAX_ATTACHMENT_BYTES) {
7
+ const chunks = [];
8
+ let total = 0;
9
+ for await (const chunk of body) {
10
+ total += chunk.byteLength;
11
+ if (total > maxBytes)
12
+ throw new TooLarge(`larger than ${maxBytes} bytes`);
13
+ chunks.push(chunk);
14
+ }
15
+ const bytes = new Uint8Array(total);
16
+ let offset = 0;
17
+ for (const chunk of chunks) {
18
+ bytes.set(chunk, offset);
19
+ offset += chunk.byteLength;
20
+ }
21
+ return bytes;
22
+ }
23
+ /**
24
+ * GETs an HTTPS URL into memory, at most `maxBytes`. Redirects are followed by hand so `headers`, which may carry a
25
+ * token, go only to the first URL's origin.
26
+ */
27
+ export async function fetchCapped(url, options) {
28
+ const origin = new URL(url).origin;
29
+ let next = new URL(url);
30
+ for (let redirects = 0;; redirects++) {
31
+ if (next.protocol !== "https:")
32
+ throw new Error(`refusing to fetch ${next.protocol} URL`);
33
+ const response = await fetch(next, {
34
+ redirect: "manual",
35
+ signal: options.signal,
36
+ ...(next.origin === origin && options.headers !== undefined ? { headers: options.headers } : {}),
37
+ });
38
+ const location = response.headers.get("location");
39
+ if (response.status >= 300 && response.status < 400 && location !== null) {
40
+ await response.body?.cancel();
41
+ if (redirects >= MAX_REDIRECTS)
42
+ throw new Error("too many redirects");
43
+ next = new URL(location, next);
44
+ continue;
45
+ }
46
+ if (!response.ok || response.body === null) {
47
+ await response.body?.cancel();
48
+ throw new Error(`${next.origin}${next.pathname} answered ${response.status}`);
49
+ }
50
+ if (response.headers.get("content-type")?.startsWith("text/html") === true) {
51
+ await response.body.cancel();
52
+ throw new Error(`${next.origin}${next.pathname} answered with a web page, not the file`);
53
+ }
54
+ const maxBytes = options.maxBytes ?? MAX_ATTACHMENT_BYTES;
55
+ if (Number(response.headers.get("content-length") ?? 0) > maxBytes) {
56
+ await response.body.cancel();
57
+ throw new TooLarge(`larger than ${maxBytes} bytes`);
58
+ }
59
+ return readCapped(response.body, maxBytes);
60
+ }
61
+ }
@@ -0,0 +1,54 @@
1
+ import { type chat_v1 } from "@googleapis/chat";
2
+ import { type Certificates } from "google-auth-library";
3
+ import type { Hono } from "hono";
4
+ import { type AdapterHandlers, type ApprovalDecision, type ApprovalOutcome, type ApprovalPrompt, type Attachment, type ChatAdapter, type ChatUser, type InboundMessage, type MessageRef, type ThreadRef } from "./adapter.ts";
5
+ /** The thread ID of a DM's top level: one conversation per DM, answered without a thread. */
6
+ export declare const DM_TOP_LEVEL = "dm";
7
+ type GoogleChatEvent = {
8
+ kind: "message";
9
+ message: InboundMessage;
10
+ } | {
11
+ kind: "approval";
12
+ decision: ApprovalDecision;
13
+ addOn: boolean;
14
+ } | {
15
+ kind: "ignored";
16
+ };
17
+ export declare function normalizeGoogleChatEvent(body: unknown): GoogleChatEvent;
18
+ type CertificateSource = (url: string) => Promise<Certificates>;
19
+ /**
20
+ * Accepts only this project's requests: a Chat API app with the "Project Number" authentication audience gets a JWT
21
+ * from Chat for the project number; a Workspace add-on Chat app gets a Google ID token for the endpoint URL from the
22
+ * project's own add-on service agent.
23
+ */
24
+ export declare function googleTokenVerifier(projectNumber: string, endpointUrl: string, certificates?: CertificateSource): (authorization: string | undefined) => Promise<boolean>;
25
+ /** The Chat API calls the adapter makes, so it can be tested without Google. */
26
+ export interface GoogleChatApi {
27
+ create(parent: string, message: chat_v1.Schema$Message, threaded: boolean): Promise<string>;
28
+ patch(name: string, message: chat_v1.Schema$Message, updateMask: string): Promise<void>;
29
+ remove(name: string): Promise<void>;
30
+ list(parent: string, filter: string): Promise<unknown[]>;
31
+ download(resourceName: string, signal: AbortSignal): Promise<Uint8Array>;
32
+ }
33
+ /**
34
+ * Listing messages needs chat.app.messages.readonly, which an admin must grant, so it has its own client and posting
35
+ * keeps working without it.
36
+ */
37
+ export declare function googleChatApi(keyFile: string): GoogleChatApi;
38
+ /** Google Chat as an HTTP endpoint app: Google posts events to `endpointUrl`; replies go out through the Chat API. */
39
+ export declare class GoogleChatAdapter implements ChatAdapter {
40
+ #private;
41
+ readonly platform = "googlechat";
42
+ readonly maxMessageLength = 4000;
43
+ constructor(http: Hono, endpointUrl: string, api: GoogleChatApi, verify: (authorization: string | undefined) => Promise<boolean>);
44
+ start(handlers: AdapterHandlers): Promise<void>;
45
+ stop(): Promise<void>;
46
+ post(thread: ThreadRef, markdown: string): Promise<MessageRef>;
47
+ update(ref: MessageRef, markdown: string): Promise<void>;
48
+ remove(ref: MessageRef): Promise<void>;
49
+ postApproval(thread: ThreadRef, prompt: ApprovalPrompt): Promise<MessageRef>;
50
+ resolveApprovalMessage(ref: MessageRef, prompt: ApprovalPrompt, outcome: ApprovalOutcome, by?: ChatUser): Promise<void>;
51
+ download(attachment: Attachment, signal: AbortSignal): Promise<Uint8Array>;
52
+ fetchThread(thread: ThreadRef, after: string | undefined): Promise<InboundMessage[]>;
53
+ }
54
+ export {};