@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/dist/start.js ADDED
@@ -0,0 +1,148 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { createRegistry } from "@earendil-works/pi-durable";
4
+ import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
5
+ import { ConsoleAdapter } from "./chat/console.js";
6
+ import { GoogleChatAdapter, googleChatApi, googleTokenVerifier } from "./chat/googlechat.js";
7
+ import { HttpServer } from "./chat/http.js";
8
+ import { SlackAdapter } from "./chat/slack.js";
9
+ import { TeamsAdapter } from "./chat/teams.js";
10
+ import { ConfigError, loadConfig } from "./config.js";
11
+ import { confluenceSource } from "./docs/confluence.js";
12
+ import { driveSource, googleDriveApi } from "./docs/drive.js";
13
+ import { DocsIndex } from "./docs/index.js";
14
+ import { localSource } from "./docs/local.js";
15
+ import { startDocsSync } from "./docs/sync.js";
16
+ import { docsExtension } from "./docs/tools.js";
17
+ import { setApprovalTransport } from "./extensions/approvals.js";
18
+ import { startMcp } from "./extensions/mcp.js";
19
+ import { context, openHarness } from "./harness.js";
20
+ import { acquireLock } from "./lock.js";
21
+ import { createModelSetup, modelKeyEnv } from "./model.js";
22
+ import { PromptExtension } from "./prompt.js";
23
+ import { turnRunner } from "./turn.js";
24
+ function docsSource(source) {
25
+ switch (source.type) {
26
+ case "local":
27
+ return localSource(source.name, source.path, source.url_base);
28
+ case "google_drive": {
29
+ const [keyFile = ""] = requireEnv("GOOGLE_SERVICE_ACCOUNT_FILE");
30
+ return driveSource(source.name, source.folder_id, googleDriveApi(keyFile));
31
+ }
32
+ case "confluence": {
33
+ const [email = "", apiToken = ""] = requireEnv("CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN");
34
+ const scope = {
35
+ site: source.site,
36
+ ...(source.space_key === undefined ? {} : { spaceKey: source.space_key }),
37
+ ...(source.parent_page_id === undefined ? {} : { parentPageId: source.parent_page_id }),
38
+ };
39
+ return confluenceSource(source.name, scope, { email, apiToken });
40
+ }
41
+ default:
42
+ throw new ConfigError(`docs.sources: unknown type ${JSON.stringify(source)}`);
43
+ }
44
+ }
45
+ function requireEnv(...names) {
46
+ const missing = names.filter((name) => (process.env[name] ?? "") === "");
47
+ if (missing.length > 0)
48
+ throw new ConfigError(`environment: set ${missing.join(", ")} in .env`);
49
+ return names.map((name) => process.env[name] ?? "");
50
+ }
51
+ /** Checks each enabled platform's secrets now; the adapters are built once the harness is open. */
52
+ function chatAdapters(config, consoleMode) {
53
+ if (consoleMode)
54
+ return [() => new ConsoleAdapter()];
55
+ const factories = [];
56
+ if (config.chat.slack.enabled) {
57
+ const [botToken = "", appToken = ""] = requireEnv("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN");
58
+ factories.push(() => new SlackAdapter(botToken, appToken));
59
+ }
60
+ if (config.chat.teams.enabled) {
61
+ const [clientId = "", clientSecret = "", tenantId = ""] = requireEnv("TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID");
62
+ factories.push((http) => new TeamsAdapter(http.app, { clientId, clientSecret, tenantId }));
63
+ }
64
+ const { enabled, endpoint_url: endpointUrl, project_number: projectNumber } = config.chat.googlechat;
65
+ if (enabled && endpointUrl !== undefined && projectNumber !== undefined) {
66
+ const [keyFile = ""] = requireEnv("GOOGLE_SERVICE_ACCOUNT_FILE");
67
+ const verify = googleTokenVerifier(projectNumber, endpointUrl);
68
+ factories.push((http) => new GoogleChatAdapter(http.app, endpointUrl, googleChatApi(keyFile), verify));
69
+ }
70
+ if (factories.length === 0) {
71
+ throw new ConfigError("chat: no chat platform is enabled; enable one in config.yaml or run `npm run console`");
72
+ }
73
+ return factories;
74
+ }
75
+ async function run(options) {
76
+ const config = loadConfig(resolve(options.config));
77
+ const dataDir = resolve(options.data);
78
+ requireEnv(modelKeyEnv(config.model));
79
+ const modelSetup = createModelSetup(config.model);
80
+ const adapterFactories = chatAdapters(config, process.argv.includes("--console"));
81
+ const sources = config.docs.sources.map(docsSource);
82
+ mkdirSync(dataDir, { recursive: true });
83
+ const releaseLock = acquireLock(dataDir);
84
+ const index = new DocsIndex(join(dataDir, "docs.sqlite"));
85
+ const registry = createRegistry();
86
+ registry.install(PromptExtension);
87
+ registry.install(docsExtension(index));
88
+ for (const extension of options.extensions ?? [])
89
+ registry.install(extension);
90
+ const stopMcp = startMcp(registry, config.mcp);
91
+ const harness = await openHarness(join(dataDir, "session.sqlite"), modelSetup.models, registry);
92
+ const http = new HttpServer();
93
+ const adapters = adapterFactories.map((create) => create(http));
94
+ const byPlatform = new Map(adapters.map((adapter) => [adapter.platform, adapter]));
95
+ setApprovalTransport({ adapters: byPlatform, timeoutMs: config.approvals.timeout_minutes * 60_000 });
96
+ const { imageLimits } = modelSetup;
97
+ const runner = turnRunner(harness, {
98
+ model: modelSetup.model,
99
+ ...(config.system_prompt === undefined ? {} : { instructions: config.system_prompt }),
100
+ }, imageLimits === undefined ? undefined : { processor: await createNodePhotonImages(), limits: imageLimits });
101
+ const stopSync = startDocsSync(index, sources, config.docs.sync_interval_minutes);
102
+ let stopping;
103
+ const shutdown = () => {
104
+ stopping ??= (async () => {
105
+ await Promise.allSettled(adapters.map((adapter) => adapter.stop()));
106
+ await http.stop();
107
+ await stopSync();
108
+ await stopMcp();
109
+ await harness.close(context);
110
+ index.close();
111
+ releaseLock();
112
+ process.exit(0);
113
+ })();
114
+ return stopping;
115
+ };
116
+ process.once("SIGINT", () => void shutdown());
117
+ process.once("SIGTERM", () => void shutdown());
118
+ void harness.closed.then((end) => {
119
+ if (end.reason === "failed") {
120
+ console.error("[harness] storage failed; exiting so the process restarts", end.error);
121
+ process.exit(1);
122
+ }
123
+ });
124
+ await Promise.all(adapters.map((adapter) => adapter.start(runner.handlers(adapter))));
125
+ if (adapters.some((adapter) => adapter.platform === "teams" || adapter.platform === "googlechat")) {
126
+ await http.start(config.http.port);
127
+ console.log(`[housecall] listening on :${config.http.port}`);
128
+ }
129
+ harness.resume();
130
+ void runner.resume(byPlatform);
131
+ for (const adapter of adapters)
132
+ if (adapter instanceof ConsoleAdapter)
133
+ void adapter.ended.then(shutdown);
134
+ console.log(`[housecall] up: ${adapters.map((adapter) => adapter.platform).join(", ")}`);
135
+ }
136
+ /**
137
+ * Starts @housecall and returns once it is up; on SIGINT or SIGTERM it shuts down and exits the process. `--console` in
138
+ * process.argv selects the console adapter.
139
+ */
140
+ export async function start(options) {
141
+ try {
142
+ await run(options);
143
+ }
144
+ catch (error) {
145
+ console.error(error instanceof ConfigError ? `@housecall cannot start:\n${error.message}` : error);
146
+ process.exit(1);
147
+ }
148
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from "@earendil-works/chord";
2
+ import { type Conversation, type Harness, type ModelRef, type ToolExecutionApi } from "@earendil-works/pi-durable";
3
+ import type { ChatUser, ThreadRef } from "./chat/adapter.ts";
4
+ export type AgentChoice = {
5
+ model: ModelRef;
6
+ instructions?: string;
7
+ };
8
+ /** The chat thread a conversation answers: tools find where to ask for approval here. */
9
+ export declare const ThreadDoc: import("@earendil-works/pi-durable").ConversationDocToken<ThreadRef>;
10
+ /** Each chat message is placed in the transcript as this entry, with its author as data. */
11
+ export declare const ChatMessageEntry: import("@earendil-works/pi-durable").Entry<ChatUser>;
12
+ /**
13
+ * The author of the newest chat message the model has seen: the person a tool acts for. Steered messages are placed
14
+ * only after the running tool round, so during a tool call this is the author of the input that led to it.
15
+ */
16
+ export declare function requester(api: ToolExecutionApi, ctx: Context): Promise<ChatUser | undefined>;
17
+ export declare function openThread(harness: Harness, thread: ThreadRef, agent: AgentChoice): Promise<Conversation>;
18
+ export declare function lastSeenCursor(harness: Harness, thread: ThreadRef): Promise<string | undefined>;
19
+ export declare function markThreadSeen(harness: Harness, thread: ThreadRef, cursor: string): Promise<void>;
@@ -0,0 +1,73 @@
1
+ import { configure, defineDoc, defineEntry, defineDocFamily, } from "@earendil-works/pi-durable";
2
+ import { context } from "./harness.js";
3
+ const Threads = defineDocFamily({
4
+ kind: "app.threads",
5
+ version: 1,
6
+ scope: "session",
7
+ family: true,
8
+ initial: () => ({ conversationId: 0, lastSeenCursor: "" }),
9
+ checkpointWhen: () => true,
10
+ });
11
+ /** The chat thread a conversation answers: tools find where to ask for approval here. */
12
+ export const ThreadDoc = defineDoc({
13
+ kind: "app.thread",
14
+ version: 1,
15
+ scope: "conversation",
16
+ history: "latest",
17
+ fork: "current",
18
+ initial: () => ({ platform: "console", spaceId: "", threadId: "" }),
19
+ checkpointWhen: () => true,
20
+ });
21
+ /** Each chat message is placed in the transcript as this entry, with its author as data. */
22
+ export const ChatMessageEntry = defineEntry("app.chat_message");
23
+ /**
24
+ * The author of the newest chat message the model has seen: the person a tool acts for. Steered messages are placed
25
+ * only after the running tool round, so during a tool call this is the author of the input that led to it.
26
+ */
27
+ export async function requester(api, ctx) {
28
+ return api.commit(async (tx) => {
29
+ let cursor;
30
+ do {
31
+ const page = await tx.scanEntries({ conversationId: api.conversationId, order: "descending" }, 50, cursor);
32
+ for (const entry of page.items)
33
+ if (ChatMessageEntry.is(entry))
34
+ return entry.data;
35
+ cursor = page.next;
36
+ } while (cursor !== undefined);
37
+ return undefined;
38
+ }, ctx);
39
+ }
40
+ function threadKey(thread) {
41
+ return `${thread.platform}:${thread.spaceId}:${thread.threadId}`;
42
+ }
43
+ export async function openThread(harness, thread, agent) {
44
+ const id = await harness.commit(async (tx) => {
45
+ const mapping = await tx.doc(Threads, threadKey(thread), null);
46
+ let conversationId = mapping.conversationId;
47
+ if (mapping.conversationId === 0) {
48
+ conversationId = (await tx.createConversation({ ownership: { kind: "ownerless" } })).id;
49
+ mapping.conversationId = conversationId;
50
+ }
51
+ await configure(tx, conversationId, { model: agent.model, instructions: agent.instructions ?? null });
52
+ const info = await tx.doc(ThreadDoc, conversationId);
53
+ info.platform = thread.platform;
54
+ info.spaceId = thread.spaceId;
55
+ info.threadId = thread.threadId;
56
+ return conversationId;
57
+ }, context);
58
+ const conversation = await harness.conversation(id, context);
59
+ if (conversation === undefined)
60
+ throw new Error(`conversation ${id} missing after commit`);
61
+ return conversation;
62
+ }
63
+ export async function lastSeenCursor(harness, thread) {
64
+ const mapping = await harness.snapshot(Threads, threadKey(thread), context);
65
+ return mapping?.lastSeenCursor === "" ? undefined : mapping?.lastSeenCursor;
66
+ }
67
+ export async function markThreadSeen(harness, thread, cursor) {
68
+ await harness.commit(async (tx) => {
69
+ const mapping = await tx.doc(Threads, threadKey(thread), null);
70
+ if (cursor > mapping.lastSeenCursor)
71
+ mapping.lastSeenCursor = cursor;
72
+ }, context);
73
+ }
package/dist/turn.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type Harness } from "@earendil-works/pi-durable";
2
+ import { type ImageOptions } from "./attachments.ts";
3
+ import type { AdapterHandlers, ChatAdapter, InboundMessage, Platform } from "./chat/adapter.ts";
4
+ import { type AgentChoice } from "./threads.ts";
5
+ type TurnRunner = {
6
+ run(adapter: ChatAdapter, message: InboundMessage): Promise<void>;
7
+ resume(adapters: ReadonlyMap<Platform, ChatAdapter>): Promise<void>;
8
+ /** What an adapter calls: DMs and mentions start turns, button clicks decide approvals. */
9
+ handlers(adapter: ChatAdapter): AdapterHandlers;
10
+ };
11
+ export declare function turnRunner(harness: Harness, agent: AgentChoice, images: ImageOptions | undefined): TurnRunner;
12
+ export {};
package/dist/turn.js ADDED
@@ -0,0 +1,206 @@
1
+ import { AssistantEntry, defineDoc, LiveDoc, } from "@earendil-works/pi-durable";
2
+ import { attachmentMarks, inputContent, readAttachments } from "./attachments.js";
3
+ import { resolveApproval } from "./extensions/approvals.js";
4
+ import { answerText, chunkText, liveStatusText, PLACEHOLDER } from "./format.js";
5
+ import { context } from "./harness.js";
6
+ import { ChatMessageEntry, lastSeenCursor, markThreadSeen, openThread } from "./threads.js";
7
+ /** Turns whose answer has not been posted yet, keyed by request ID; a restart finishes them. */
8
+ const Turns = defineDoc({
9
+ kind: "app.turns",
10
+ version: 1,
11
+ scope: "session",
12
+ initial: () => ({ pending: {} }),
13
+ checkpointWhen: () => true,
14
+ });
15
+ const BLANK_MESSAGE = "(The user mentioned you without a message. Read the thread and help with what they need.)";
16
+ const RESTARTED = "I restarted before I could read this. Please send it again.";
17
+ const STATUS_INTERVAL_MS = 1200;
18
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
19
+ export function turnRunner(harness, agent, images) {
20
+ const inFlight = new Set();
21
+ /**
22
+ * One run answers every input steered into it; the first of their turns posts the answer. Kept per conversation
23
+ * until none of its turns is finishing.
24
+ */
25
+ const posted = new Map();
26
+ const finishing = new Map();
27
+ const admitting = new Map();
28
+ let closing = false;
29
+ harness.subscribeClose(() => {
30
+ closing = true;
31
+ });
32
+ const admitted = (conversation, requestId) => conversation.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context);
33
+ const forget = (requestId) => harness.commit(async (tx) => {
34
+ delete (await tx.doc(Turns)).pending[requestId];
35
+ }, context);
36
+ async function withHistory(adapter, message) {
37
+ const text = message.text === "" && message.attachments.length === 0 ? BLANK_MESSAGE : message.text;
38
+ if (message.isDirect)
39
+ return { text, earlier: [] };
40
+ const after = await lastSeenCursor(harness, message);
41
+ const earlier = (await adapter.fetchThread(message, after).catch(() => [])).filter((other) => other.cursor < message.cursor && !other.isDirect && !other.mentionsBot);
42
+ await markThreadSeen(harness, message, message.cursor);
43
+ if (earlier.length === 0)
44
+ return { text, earlier };
45
+ const lines = earlier.map((other) => `${other.user.name ?? other.user.id}: ${other.text}${attachmentMarks(other)}`);
46
+ return {
47
+ text: `Messages in this thread since you last replied:\n${lines.join("\n")}\n\nThe message for you:\n${text}`,
48
+ earlier,
49
+ };
50
+ }
51
+ async function finish(adapter, conversation, requestId, ref, submission) {
52
+ finishing.set(conversation.id, (finishing.get(conversation.id) ?? 0) + 1);
53
+ try {
54
+ await settle(adapter, conversation, ref, submission);
55
+ await forget(requestId);
56
+ }
57
+ finally {
58
+ const left = (finishing.get(conversation.id) ?? 1) - 1;
59
+ if (left === 0) {
60
+ finishing.delete(conversation.id);
61
+ posted.delete(conversation.id);
62
+ }
63
+ else {
64
+ finishing.set(conversation.id, left);
65
+ }
66
+ }
67
+ }
68
+ async function settle(adapter, conversation, ref, submission) {
69
+ let finished = false;
70
+ let shown = "";
71
+ let lastUpdate = 0;
72
+ let updating = Promise.resolve();
73
+ const live = await harness.watchDoc(LiveDoc, conversation.id, context);
74
+ live?.start(async (value) => {
75
+ const text = liveStatusText(value).slice(-adapter.maxMessageLength);
76
+ if (finished || text === shown || Date.now() - lastUpdate < STATUS_INTERVAL_MS)
77
+ return;
78
+ shown = text;
79
+ lastUpdate = Date.now();
80
+ updating = adapter.update(ref, text).catch(() => undefined);
81
+ await updating;
82
+ });
83
+ const settled = await submission.wait(context);
84
+ finished = true;
85
+ await live?.stop();
86
+ await updating;
87
+ let reply;
88
+ switch (settled.status) {
89
+ case "done": {
90
+ if (settled.type === "write")
91
+ throw new Error("turns submit inputs, not writes");
92
+ const answers = posted.get(conversation.id) ?? new Set();
93
+ if (answers.has(settled.answer)) {
94
+ await adapter.remove(ref).catch(() => undefined);
95
+ return;
96
+ }
97
+ posted.set(conversation.id, answers.add(settled.answer));
98
+ reply = answerText(await conversation.commit((tx) => tx.entry(AssistantEntry, settled.answer), context));
99
+ break;
100
+ }
101
+ case "unanswered":
102
+ console.error(`[turn] unanswered ${settled.reason} ${JSON.stringify(settled.detail ?? null)}`);
103
+ reply = `Sorry, I could not answer that (${settled.reason}).`;
104
+ break;
105
+ default:
106
+ throw new Error(`unknown submission status ${JSON.stringify(settled)}`);
107
+ }
108
+ const [first = "", ...rest] = chunkText(reply === "" ? "(no answer)" : reply, adapter.maxMessageLength);
109
+ await adapter.update(ref, first);
110
+ for (const chunk of rest)
111
+ await adapter.post(ref, chunk);
112
+ }
113
+ /** The turn's submission, once its input is admitted; inputs of one thread are admitted in the order they came. */
114
+ async function admit(adapter, message, requestId) {
115
+ const conversation = await openThread(harness, message, agent);
116
+ if ((await admitted(conversation, requestId)) !== undefined)
117
+ return undefined;
118
+ const ref = await adapter.post(message, PLACEHOLDER);
119
+ await harness.commit(async (tx) => {
120
+ (await tx.doc(Turns)).pending[requestId] = { ref, conversationId: conversation.id };
121
+ }, context);
122
+ const { text, earlier } = await withHistory(adapter, message);
123
+ const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
124
+ const read = await readAttachments(adapter, message, earlier, images, signal);
125
+ const submission = await conversation.submit({
126
+ type: "input",
127
+ content: inputContent(text, read),
128
+ whenBusy: "steer",
129
+ requestId,
130
+ entry: { kind: ChatMessageEntry.kind, data: message.user },
131
+ }, context);
132
+ return { conversation, ref, submission };
133
+ }
134
+ async function run(adapter, message) {
135
+ const requestId = `${message.platform}:${message.messageId}`;
136
+ if (inFlight.has(requestId))
137
+ return;
138
+ inFlight.add(requestId);
139
+ try {
140
+ const thread = `${message.platform}:${message.spaceId}:${message.threadId}`;
141
+ const turn = (admitting.get(thread) ?? Promise.resolve()).then(() => admit(adapter, message, requestId));
142
+ const done = turn.then(() => undefined, () => undefined);
143
+ admitting.set(thread, done);
144
+ void done.then(() => {
145
+ if (admitting.get(thread) === done)
146
+ admitting.delete(thread);
147
+ });
148
+ const admittedTurn = await turn;
149
+ if (admittedTurn !== undefined) {
150
+ await finish(adapter, admittedTurn.conversation, requestId, admittedTurn.ref, admittedTurn.submission);
151
+ }
152
+ }
153
+ finally {
154
+ inFlight.delete(requestId);
155
+ }
156
+ }
157
+ async function resumeOne(adapter, requestId, turn) {
158
+ const conversation = await harness.conversation(turn.conversationId, context);
159
+ const record = conversation === undefined ? undefined : await admitted(conversation, requestId);
160
+ const submission = record === undefined ? undefined : await harness.submission(record.id, context);
161
+ if (conversation === undefined || submission === undefined) {
162
+ await adapter.update(turn.ref, RESTARTED).catch(() => undefined);
163
+ await forget(requestId);
164
+ return;
165
+ }
166
+ await finish(adapter, conversation, requestId, turn.ref, submission);
167
+ }
168
+ async function resume(adapters) {
169
+ const pending = (await harness.snapshot(Turns, context))?.pending ?? {};
170
+ await Promise.all(Object.entries(pending).map(async ([requestId, turn]) => {
171
+ const adapter = adapters.get(turn.ref.platform);
172
+ if (adapter === undefined || inFlight.has(requestId))
173
+ return;
174
+ inFlight.add(requestId);
175
+ try {
176
+ await resumeOne(adapter, requestId, turn);
177
+ }
178
+ catch (error) {
179
+ if (!closing)
180
+ console.error(`[turn] could not finish ${requestId}`, error);
181
+ }
182
+ finally {
183
+ inFlight.delete(requestId);
184
+ }
185
+ }));
186
+ }
187
+ function handlers(adapter) {
188
+ return {
189
+ onMessage: async (message) => {
190
+ if (!message.isDirect && !message.mentionsBot)
191
+ return;
192
+ await run(adapter, message).catch((error) => {
193
+ if (!closing)
194
+ console.error("[turn] failed", error);
195
+ });
196
+ },
197
+ onApproval: async (decision) => {
198
+ const feedback = await resolveApproval(harness, decision);
199
+ if (!feedback.counted)
200
+ console.log(`[approvals] ${decision.approvalId} ${decision.user.id}: ${feedback.text}`);
201
+ return feedback;
202
+ },
203
+ };
204
+ }
205
+ return { run, resume, handlers };
206
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@ithousecall/chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Self-hosted IT helpdesk chat agent for Slack, Microsoft Teams, and Google Chat",
5
+ "homepage": "https://chat.ithousecall.app",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/IT-Housecall/it-housecall-chat-agent.git"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "npm run build",
27
+ "check": "tsc --noEmit",
28
+ "lint": "oxlint --deny-warnings --type-aware && oxfmt --check .",
29
+ "format": "oxfmt .",
30
+ "test": "vitest run",
31
+ "ci": "npm run check && npm run lint && npm test"
32
+ },
33
+ "dependencies": {
34
+ "@earendil-works/chord": "1.4.0",
35
+ "@earendil-works/pi-ai": "1.4.0",
36
+ "@earendil-works/pi-durable": "1.4.0",
37
+ "@earendil-works/pi-mcp": "1.4.0",
38
+ "@googleapis/chat": "51.3.0",
39
+ "@googleapis/drive": "27.0.0",
40
+ "@hono/node-server": "2.1.5",
41
+ "@microsoft/teams.api": "2.1.1",
42
+ "@microsoft/teams.apps": "2.1.1",
43
+ "@microsoft/teams.cards": "2.1.1",
44
+ "@slack/bolt": "5.1.0",
45
+ "google-auth-library": "11.3.1",
46
+ "hono": "4.13.13",
47
+ "turndown": "7.2.4",
48
+ "yaml": "2.9.1",
49
+ "zod": "4.6.7"
50
+ },
51
+ "devDependencies": {
52
+ "@slack/types": "3.2.0",
53
+ "@types/node": "26.6.5",
54
+ "@types/turndown": "5.0.6",
55
+ "oxfmt": "0.74.0",
56
+ "oxlint": "1.89.0",
57
+ "oxlint-tsgolint": "7.0.2005",
58
+ "typescript": "5.9.3",
59
+ "vitest": "5.0.5"
60
+ },
61
+ "engines": {
62
+ "node": ">=24"
63
+ }
64
+ }