@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.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/attachments.d.ts +32 -0
- package/dist/attachments.js +159 -0
- package/dist/chat/adapter.d.ts +77 -0
- package/dist/chat/adapter.js +23 -0
- package/dist/chat/console.d.ts +21 -0
- package/dist/chat/console.js +105 -0
- package/dist/chat/download.d.ts +13 -0
- package/dist/chat/download.js +61 -0
- package/dist/chat/googlechat.d.ts +54 -0
- package/dist/chat/googlechat.js +329 -0
- package/dist/chat/http.d.ts +9 -0
- package/dist/chat/http.js +21 -0
- package/dist/chat/markdown.d.ts +4 -0
- package/dist/chat/markdown.js +28 -0
- package/dist/chat/slack.d.ts +55 -0
- package/dist/chat/slack.js +197 -0
- package/dist/chat/teams.d.ts +75 -0
- package/dist/chat/teams.js +202 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +155 -0
- package/dist/docs/chunk.d.ts +7 -0
- package/dist/docs/chunk.js +70 -0
- package/dist/docs/confluence.d.ts +12 -0
- package/dist/docs/confluence.js +68 -0
- package/dist/docs/drive.d.ts +20 -0
- package/dist/docs/drive.js +85 -0
- package/dist/docs/html.d.ts +2 -0
- package/dist/docs/html.js +13 -0
- package/dist/docs/index.d.ts +31 -0
- package/dist/docs/index.js +146 -0
- package/dist/docs/local.d.ts +3 -0
- package/dist/docs/local.js +39 -0
- package/dist/docs/source.d.ts +14 -0
- package/dist/docs/source.js +1 -0
- package/dist/docs/sync.d.ts +13 -0
- package/dist/docs/sync.js +68 -0
- package/dist/docs/tools.d.ts +3 -0
- package/dist/docs/tools.js +59 -0
- package/dist/extensions/approvals.d.ts +26 -0
- package/dist/extensions/approvals.js +144 -0
- package/dist/extensions/mcp.d.ts +10 -0
- package/dist/extensions/mcp.js +127 -0
- package/dist/extensions/tool.d.ts +14 -0
- package/dist/extensions/tool.js +8 -0
- package/dist/format.d.ts +7 -0
- package/dist/format.js +36 -0
- package/dist/harness.d.ts +4 -0
- package/dist/harness.js +14 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.js +18 -0
- package/dist/model.d.ts +14 -0
- package/dist/model.js +87 -0
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +15 -0
- package/dist/start.d.ts +13 -0
- package/dist/start.js +148 -0
- package/dist/threads.d.ts +19 -0
- package/dist/threads.js +73 -0
- package/dist/turn.d.ts +12 -0
- package/dist/turn.js +206 -0
- package/package.json +62 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { defineDocFamily, } from "@earendil-works/pi-durable";
|
|
2
|
+
import { decisionText, } from "../chat/adapter.js";
|
|
3
|
+
import { context } from "../harness.js";
|
|
4
|
+
import { requester, ThreadDoc } from "../threads.js";
|
|
5
|
+
const Approvals = defineDocFamily({
|
|
6
|
+
kind: "app.approvals",
|
|
7
|
+
version: 1,
|
|
8
|
+
scope: "session",
|
|
9
|
+
family: true,
|
|
10
|
+
initial: (seed) => ({ ...seed, status: "pending", decidedBy: null }),
|
|
11
|
+
checkpointWhen: () => true,
|
|
12
|
+
});
|
|
13
|
+
let transport;
|
|
14
|
+
/** Write tools ask for approval through these adapters; set once at startup. */
|
|
15
|
+
export function setApprovalTransport(next) {
|
|
16
|
+
transport = next;
|
|
17
|
+
}
|
|
18
|
+
function failure(text) {
|
|
19
|
+
return { output: [{ type: "text", text }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
22
|
+
async function awaitDecision(api, seed, deadline, ctx) {
|
|
23
|
+
const approvalId = seed.prompt.approvalId;
|
|
24
|
+
const watch = await api.watchDoc(Approvals, approvalId, ctx);
|
|
25
|
+
if (watch === undefined)
|
|
26
|
+
throw new Error(`approval ${approvalId} is missing`);
|
|
27
|
+
const expire = () => api
|
|
28
|
+
.commit(async (tx) => {
|
|
29
|
+
const approval = await tx.doc(Approvals, approvalId, seed);
|
|
30
|
+
if (approval.status === "pending")
|
|
31
|
+
approval.status = "expired";
|
|
32
|
+
}, ctx)
|
|
33
|
+
.catch(() => undefined);
|
|
34
|
+
const timer = setTimeout(() => void expire(), Math.min(MAX_TIMER_MS, Math.max(0, deadline - api.now())));
|
|
35
|
+
try {
|
|
36
|
+
return await new Promise((resolve, reject) => {
|
|
37
|
+
const check = (value) => {
|
|
38
|
+
if (value !== null && value.status !== "pending")
|
|
39
|
+
resolve(value.status);
|
|
40
|
+
};
|
|
41
|
+
check(watch.value);
|
|
42
|
+
watch.start(async (value) => check(value));
|
|
43
|
+
void watch.closed.then((end) => reject(new Error(`approval watch ended: ${end.reason}`)));
|
|
44
|
+
ctx.abortSignal?.addEventListener("abort", () => reject(new Error("approval wait aborted")), { once: true });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
await watch.stop();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A tool that runs only after its requester, the author of the message that led to the call, confirms it in chat.
|
|
54
|
+
* Replay-safe: after a restart the call reruns, finds its approval and posted prompt, and keeps waiting.
|
|
55
|
+
*/
|
|
56
|
+
export function withApproval(tool, summarize) {
|
|
57
|
+
return {
|
|
58
|
+
...tool,
|
|
59
|
+
replay: "safe",
|
|
60
|
+
execute: async (args, api, ctx) => {
|
|
61
|
+
const gate = transport;
|
|
62
|
+
if (gate === undefined)
|
|
63
|
+
return failure("Approvals are not set up, so this action cannot run.");
|
|
64
|
+
const thread = await api.snapshot(ThreadDoc, api.conversationId, ctx);
|
|
65
|
+
const adapter = thread === undefined ? undefined : gate.adapters.get(thread.platform);
|
|
66
|
+
const asker = await requester(api, ctx);
|
|
67
|
+
if (thread === undefined || adapter === undefined || asker === undefined) {
|
|
68
|
+
return failure("This action needs a chat thread to ask for approval in.");
|
|
69
|
+
}
|
|
70
|
+
const approvalId = String(api.taskId);
|
|
71
|
+
await api.commit(async (tx) => {
|
|
72
|
+
await tx.doc(Approvals, approvalId, {
|
|
73
|
+
prompt: {
|
|
74
|
+
approvalId,
|
|
75
|
+
tool: tool.name,
|
|
76
|
+
summary: summarize(args),
|
|
77
|
+
args: structuredClone(args),
|
|
78
|
+
requester: structuredClone(asker),
|
|
79
|
+
},
|
|
80
|
+
conversationId: api.conversationId,
|
|
81
|
+
createdAt: api.now(),
|
|
82
|
+
});
|
|
83
|
+
}, ctx);
|
|
84
|
+
const stored = await api.snapshot(Approvals, approvalId, ctx);
|
|
85
|
+
if (stored === undefined)
|
|
86
|
+
throw new Error(`approval ${approvalId} is missing`);
|
|
87
|
+
const { prompt } = stored;
|
|
88
|
+
const ref = (await api.memo("prompt", ctx)) ??
|
|
89
|
+
(await api.memo("prompt", await adapter.postApproval(thread, prompt), ctx));
|
|
90
|
+
const outcome = await awaitDecision(api, stored, stored.createdAt + gate.timeoutMs, ctx);
|
|
91
|
+
const decidedBy = (await api.snapshot(Approvals, prompt.approvalId, ctx))?.decidedBy ?? undefined;
|
|
92
|
+
await adapter
|
|
93
|
+
.resolveApprovalMessage(ref, prompt, outcome, decidedBy)
|
|
94
|
+
.catch((error) => console.error("could not update the approval message", error));
|
|
95
|
+
switch (outcome) {
|
|
96
|
+
case "approved":
|
|
97
|
+
break;
|
|
98
|
+
case "denied":
|
|
99
|
+
return failure("The user denied this action. Do not retry it unless they ask for it again.");
|
|
100
|
+
case "expired":
|
|
101
|
+
return failure("Nobody approved this action in time. Ask the user whether to try again.");
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(`unknown approval outcome ${JSON.stringify(outcome)}`);
|
|
104
|
+
}
|
|
105
|
+
if ((await api.memo("started", ctx)) === true) {
|
|
106
|
+
return failure("This action was interrupted after it started and may already have taken effect. Check before retrying.");
|
|
107
|
+
}
|
|
108
|
+
await api.memo("started", true, ctx);
|
|
109
|
+
return tool.execute(args, api, ctx);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Records the requester's decision; anyone else's click, or a late one, changes nothing. */
|
|
114
|
+
export async function resolveApproval(harness, decision) {
|
|
115
|
+
const current = await harness.snapshot(Approvals, decision.approvalId, context);
|
|
116
|
+
if (current === undefined)
|
|
117
|
+
return { counted: false, text: "This approval request no longer exists." };
|
|
118
|
+
const seed = structuredClone({
|
|
119
|
+
prompt: current.prompt,
|
|
120
|
+
conversationId: current.conversationId,
|
|
121
|
+
createdAt: current.createdAt,
|
|
122
|
+
});
|
|
123
|
+
return harness.commit(async (tx) => {
|
|
124
|
+
const approval = await tx.doc(Approvals, decision.approvalId, seed);
|
|
125
|
+
const { requester: asker } = approval.prompt;
|
|
126
|
+
if (approval.status !== "pending")
|
|
127
|
+
return { counted: false, text: `This request was already ${approval.status}.` };
|
|
128
|
+
if (asker.id !== decision.user.id) {
|
|
129
|
+
return { counted: false, text: `Only ${asker.name ?? asker.id} can approve or deny this request.` };
|
|
130
|
+
}
|
|
131
|
+
switch (decision.decision) {
|
|
132
|
+
case "approve":
|
|
133
|
+
approval.status = "approved";
|
|
134
|
+
break;
|
|
135
|
+
case "deny":
|
|
136
|
+
approval.status = "denied";
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
throw new Error(`unknown decision ${JSON.stringify(decision.decision)}`);
|
|
140
|
+
}
|
|
141
|
+
approval.decidedBy = structuredClone(decision.user);
|
|
142
|
+
return { counted: true, text: decisionText(seed.prompt, approval.status, decision.user) };
|
|
143
|
+
}, context);
|
|
144
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Registry } from "@earendil-works/pi-durable";
|
|
2
|
+
import { type McpTransport } from "@earendil-works/pi-mcp";
|
|
3
|
+
import type { McpServerConfig } from "../config.ts";
|
|
4
|
+
/** Providers accept tool names of at most 64 characters from [A-Za-z0-9_-]. */
|
|
5
|
+
export declare function mcpToolName(server: string, tool: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Connect each server in the background and install its tools as extension `mcp:<name>`. A dropped connection
|
|
8
|
+
* reconnects with backoff; meanwhile its tools answer with an error.
|
|
9
|
+
*/
|
|
10
|
+
export declare function startMcp(registry: Registry, servers: readonly McpServerConfig[], connect?: (server: McpServerConfig) => McpTransport): () => Promise<void>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import { defineExtension } from "@earendil-works/pi-durable";
|
|
4
|
+
import { McpClient, StdioTransport, StreamableHttpTransport, toLlmContent, } from "@earendil-works/pi-mcp";
|
|
5
|
+
import { defineTool } from "./tool.js";
|
|
6
|
+
/** Providers accept tool names of at most 64 characters from [A-Za-z0-9_-]. */
|
|
7
|
+
export function mcpToolName(server, tool) {
|
|
8
|
+
return `mcp__${server}__${tool}`.replaceAll(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
9
|
+
}
|
|
10
|
+
/** Tools whose housecall name another tool of the server already took are skipped. */
|
|
11
|
+
function uniquelyNamed(server, tools) {
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
return tools.filter((tool) => {
|
|
14
|
+
const name = mcpToolName(server, tool.name);
|
|
15
|
+
const taken = seen.get(name);
|
|
16
|
+
if (taken === undefined) {
|
|
17
|
+
seen.set(name, tool.name);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
console.error(`[mcp] ${server}: skipping tool "${tool.name}", its name ${name} is taken by "${taken}"`);
|
|
21
|
+
return false;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The server's tools as housecall tools. Only the tools listed in `read_only` run without approval; the server's own
|
|
26
|
+
* `readOnlyHint` is not trusted.
|
|
27
|
+
*/
|
|
28
|
+
function mcpExtension(server, tools, readOnly, client) {
|
|
29
|
+
return defineExtension({
|
|
30
|
+
name: `mcp:${server}`,
|
|
31
|
+
tools: uniquelyNamed(server, tools).map((tool) => {
|
|
32
|
+
const parameters = Type.Unsafe({
|
|
33
|
+
...tool.inputSchema,
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: tool.inputSchema.properties ?? {},
|
|
36
|
+
});
|
|
37
|
+
const write = !readOnly.includes(tool.name);
|
|
38
|
+
return defineTool({
|
|
39
|
+
name: mcpToolName(server, tool.name),
|
|
40
|
+
description: tool.description ?? tool.title ?? tool.name,
|
|
41
|
+
parameters,
|
|
42
|
+
...(write
|
|
43
|
+
? { write: true, summary: () => `${server}: ${tool.annotations?.title ?? tool.title ?? tool.name}` }
|
|
44
|
+
: {}),
|
|
45
|
+
execute: async (args, _api, context) => {
|
|
46
|
+
const connected = client();
|
|
47
|
+
if (connected?.connectionState !== "connected") {
|
|
48
|
+
return {
|
|
49
|
+
output: [{ type: "text", text: `The ${server} server is not connected right now. Try again later.` }],
|
|
50
|
+
isError: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const result = await connected.callTool(tool.name, args, context.abortSignal === undefined ? {} : { signal: context.abortSignal });
|
|
54
|
+
return { output: toLlmContent(result), isError: result.isError === true };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// MCP servers get only these variables and their configured env, never housecall's own secrets.
|
|
61
|
+
const INHERITED_ENV = ["PATH", "HOME", "USER", "LANG", "TMPDIR"];
|
|
62
|
+
function transport(server) {
|
|
63
|
+
if ("url" in server)
|
|
64
|
+
return new StreamableHttpTransport({ url: server.url, headers: server.headers });
|
|
65
|
+
const base = Object.fromEntries(INHERITED_ENV.flatMap((name) => (process.env[name] === undefined ? [] : [[name, process.env[name]]])));
|
|
66
|
+
return new StdioTransport({
|
|
67
|
+
command: server.command,
|
|
68
|
+
args: server.args,
|
|
69
|
+
env: { ...base, ...server.env },
|
|
70
|
+
inheritEnv: false,
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
onStderr: (chunk) => process.stderr.write(`[mcp:${server.name}] ${chunk}`),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
76
|
+
/**
|
|
77
|
+
* Connect each server in the background and install its tools as extension `mcp:<name>`. A dropped connection
|
|
78
|
+
* reconnects with backoff; meanwhile its tools answer with an error.
|
|
79
|
+
*/
|
|
80
|
+
export function startMcp(registry, servers, connect = transport) {
|
|
81
|
+
const stopping = new AbortController();
|
|
82
|
+
const clients = new Map();
|
|
83
|
+
/** One connection, until it drops; true if it got as far as listing tools. */
|
|
84
|
+
async function session(server) {
|
|
85
|
+
const client = new McpClient({ name: "housecall", version: "0.1.0" });
|
|
86
|
+
const close = () => void client.close().catch(() => undefined);
|
|
87
|
+
stopping.signal.addEventListener("abort", close, { once: true });
|
|
88
|
+
try {
|
|
89
|
+
const closed = new Promise((resolve) => client.onClose(resolve));
|
|
90
|
+
await client.connect(connect(server));
|
|
91
|
+
clients.set(server.name, client);
|
|
92
|
+
const install = async () => registry.install(mcpExtension(server.name, await client.listTools(), server.read_only, () => clients.get(server.name)));
|
|
93
|
+
await install();
|
|
94
|
+
client.onNotification("notifications/tools/list_changed", () => void install().catch(() => undefined));
|
|
95
|
+
console.log(`[mcp] ${server.name}: connected`);
|
|
96
|
+
await closed;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!stopping.signal.aborted) {
|
|
101
|
+
console.error(`[mcp] ${server.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
stopping.signal.removeEventListener("abort", close);
|
|
107
|
+
close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function serve(server) {
|
|
111
|
+
let backoff = 1000;
|
|
112
|
+
for (;;) {
|
|
113
|
+
if (await session(server))
|
|
114
|
+
backoff = 1000;
|
|
115
|
+
if (stopping.signal.aborted)
|
|
116
|
+
return;
|
|
117
|
+
console.error(`[mcp] ${server.name}: disconnected, retrying in ${backoff / 1000}s`);
|
|
118
|
+
await sleep(backoff, undefined, { signal: stopping.signal }).catch(() => undefined);
|
|
119
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const running = servers.map(serve);
|
|
123
|
+
return async () => {
|
|
124
|
+
stopping.abort();
|
|
125
|
+
await Promise.all(running);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord";
|
|
2
|
+
import type { Static, TSchema } from "@earendil-works/pi-ai";
|
|
3
|
+
import { type ToolExecutionApi, type ToolExecutionResult, type ToolRegistration } from "@earendil-works/pi-durable";
|
|
4
|
+
export type AgentTool<P extends TSchema> = Omit<ToolRegistration<P>, "replay" | "execute"> & {
|
|
5
|
+
/**
|
|
6
|
+
* The tool changes something outside @housecall: the requester must approve each call in chat before `execute` runs.
|
|
7
|
+
* Tools without it must be safe to run again after a restart.
|
|
8
|
+
*/
|
|
9
|
+
write?: true;
|
|
10
|
+
/** One line for the approval prompt; default: the tool name. */
|
|
11
|
+
summary?: (args: Static<P>) => string;
|
|
12
|
+
execute(args: Static<P>, api: ToolExecutionApi, context: Context): Promise<ToolExecutionResult>;
|
|
13
|
+
};
|
|
14
|
+
export declare function defineTool<P extends TSchema>(tool: AgentTool<P>): ToolRegistration<P>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineTool as defineDurableTool, } from "@earendil-works/pi-durable";
|
|
2
|
+
import { withApproval } from "./approvals.js";
|
|
3
|
+
export function defineTool(tool) {
|
|
4
|
+
const { write, summary, ...registration } = tool;
|
|
5
|
+
if (write === undefined)
|
|
6
|
+
return defineDurableTool({ ...registration, replay: "safe" });
|
|
7
|
+
return withApproval(defineDurableTool(registration), summary ?? (() => tool.name));
|
|
8
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EntryRecord, LiveState } from "@earendil-works/pi-durable";
|
|
2
|
+
export declare const PLACEHOLDER = "Looking into it\u2026";
|
|
3
|
+
export declare function escapeHtml(text: string): string;
|
|
4
|
+
export declare function answerText(entry: EntryRecord | undefined): string;
|
|
5
|
+
export declare function liveStatusText(live: Readonly<LiveState> | null): string;
|
|
6
|
+
/** Split text into pieces of at most `max` characters, preferring paragraph, then line, then word boundaries. */
|
|
7
|
+
export declare function chunkText(text: string, max: number): string[];
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const PLACEHOLDER = "Looking into it…";
|
|
2
|
+
export function escapeHtml(text) {
|
|
3
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
4
|
+
}
|
|
5
|
+
export function answerText(entry) {
|
|
6
|
+
return (entry?.model ?? [])
|
|
7
|
+
.flatMap((message) => (message.role === "assistant" ? message.content : []))
|
|
8
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
9
|
+
.join("")
|
|
10
|
+
.trim();
|
|
11
|
+
}
|
|
12
|
+
export function liveStatusText(live) {
|
|
13
|
+
const partial = (live?.generation?.message?.content ?? [])
|
|
14
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
15
|
+
.join("")
|
|
16
|
+
.trim();
|
|
17
|
+
if (partial !== "")
|
|
18
|
+
return partial;
|
|
19
|
+
const tools = (live?.tools ?? []).map((tool) => `${tool.status === "done" ? "✓" : "…"} \`${tool.name}\``);
|
|
20
|
+
return [PLACEHOLDER, ...tools].join("\n");
|
|
21
|
+
}
|
|
22
|
+
/** Split text into pieces of at most `max` characters, preferring paragraph, then line, then word boundaries. */
|
|
23
|
+
export function chunkText(text, max) {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let rest = text;
|
|
26
|
+
while (rest.length > max) {
|
|
27
|
+
const window = rest.slice(0, max);
|
|
28
|
+
const cut = [window.lastIndexOf("\n\n"), window.lastIndexOf("\n"), window.lastIndexOf(" ")].find((index) => index > max / 2);
|
|
29
|
+
const at = cut === undefined ? max : cut + 1;
|
|
30
|
+
chunks.push(rest.slice(0, at).trimEnd());
|
|
31
|
+
rest = rest.slice(at).trimStart();
|
|
32
|
+
}
|
|
33
|
+
if (rest !== "" || chunks.length === 0)
|
|
34
|
+
chunks.push(rest);
|
|
35
|
+
return chunks;
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Models } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Harness, type Registry } from "@earendil-works/pi-durable";
|
|
3
|
+
export declare const context: import("@earendil-works/chord/context").Context;
|
|
4
|
+
export declare function openHarness(path: string, models: Models, registry: Registry): Promise<Harness>;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { Harness } from "@earendil-works/pi-durable";
|
|
3
|
+
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
4
|
+
export const context = BACKGROUND_CONTEXT;
|
|
5
|
+
export async function openHarness(path, models, registry) {
|
|
6
|
+
const storage = await openNodeSqliteStorage(path);
|
|
7
|
+
return Harness.open(storage, {
|
|
8
|
+
models,
|
|
9
|
+
registry,
|
|
10
|
+
identity: { attribution: { name: "housecall", url: "https://github.com/IT-Housecall/it-housecall-chat-agent" } },
|
|
11
|
+
settings: { afterFailedRun: "continue" },
|
|
12
|
+
onReport: (error, info) => console.error(`[harness] ${info.type}`, error),
|
|
13
|
+
}, context);
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
export { defineExtension } from "@earendil-works/pi-durable";
|
|
3
|
+
export type { Extension, ToolExecutionApi, ToolExecutionResult } from "@earendil-works/pi-durable";
|
|
4
|
+
export type { ChatUser } from "./chat/adapter.ts";
|
|
5
|
+
export { defineTool, type AgentTool } from "./extensions/tool.ts";
|
|
6
|
+
export { start, type StartOptions } from "./start.ts";
|
|
7
|
+
export { requester } from "./threads.ts";
|
package/dist/index.js
ADDED
package/dist/lock.d.ts
ADDED
package/dist/lock.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { ConfigError } from "./config.js";
|
|
4
|
+
/** pi-durable storage has no cross-process locking, so one housecall process owns `dir` at a time. */
|
|
5
|
+
export function acquireLock(dir) {
|
|
6
|
+
const db = new DatabaseSync(join(dir, "housecall.lock"), { timeout: 0 });
|
|
7
|
+
try {
|
|
8
|
+
db.exec("PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE; COMMIT;");
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
db.close();
|
|
12
|
+
if (error instanceof Error && error.message.includes("database is locked")) {
|
|
13
|
+
throw new ConfigError(`another @housecall is already using ${dir}/`);
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
return () => db.close();
|
|
18
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Models } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRef } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { ImageLimits } from "@earendil-works/pi-durable/tools";
|
|
4
|
+
import { type ModelConfig } from "./config.ts";
|
|
5
|
+
/** `imageLimits` is set when the model takes image input. */
|
|
6
|
+
type ModelSetup = {
|
|
7
|
+
models: Models;
|
|
8
|
+
model: ModelRef;
|
|
9
|
+
imageLimits?: ImageLimits;
|
|
10
|
+
};
|
|
11
|
+
/** The environment variable that holds the model's API key. */
|
|
12
|
+
export declare function modelKeyEnv(config: ModelConfig): string;
|
|
13
|
+
export declare function createModelSetup(config: ModelConfig): ModelSetup;
|
|
14
|
+
export {};
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
2
|
+
import { createModels, createProvider } from "@earendil-works/pi-ai/models";
|
|
3
|
+
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
|
4
|
+
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
5
|
+
import { ConfigError } from "./config.js";
|
|
6
|
+
const PROVIDER_ID = "llm";
|
|
7
|
+
/** Anthropic scales a longer edge down to 1568 pixels anyway. */
|
|
8
|
+
const CHAT_IMAGE_LIMITS = { maxWidth: 1568, maxHeight: 1568, maxBytes: 1024 * 1024 };
|
|
9
|
+
/** The environment variable that holds the model's API key. */
|
|
10
|
+
export function modelKeyEnv(config) {
|
|
11
|
+
switch (config.provider) {
|
|
12
|
+
case "openai_compatible":
|
|
13
|
+
return config.api_key_env;
|
|
14
|
+
case "openai":
|
|
15
|
+
return "OPENAI_API_KEY";
|
|
16
|
+
case "anthropic":
|
|
17
|
+
return "ANTHROPIC_API_KEY";
|
|
18
|
+
default:
|
|
19
|
+
throw new ConfigError(`model.provider: unknown provider ${JSON.stringify(config)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function createModelSetup(config) {
|
|
23
|
+
const models = createModels();
|
|
24
|
+
switch (config.provider) {
|
|
25
|
+
case "openai_compatible": {
|
|
26
|
+
const keyEnv = config.api_key_env;
|
|
27
|
+
models.setProvider(createProvider({
|
|
28
|
+
id: PROVIDER_ID,
|
|
29
|
+
name: "OpenAI-compatible endpoint",
|
|
30
|
+
baseUrl: config.base_url,
|
|
31
|
+
auth: {
|
|
32
|
+
apiKey: {
|
|
33
|
+
name: keyEnv,
|
|
34
|
+
resolve: () => {
|
|
35
|
+
const apiKey = process.env[keyEnv];
|
|
36
|
+
return Promise.resolve(apiKey === undefined || apiKey === "" ? undefined : { auth: { apiKey }, source: keyEnv });
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
api: openAICompletionsApi(),
|
|
41
|
+
models: [
|
|
42
|
+
{
|
|
43
|
+
id: config.id,
|
|
44
|
+
name: config.id,
|
|
45
|
+
api: "openai-completions",
|
|
46
|
+
provider: PROVIDER_ID,
|
|
47
|
+
baseUrl: config.base_url,
|
|
48
|
+
input: config.images ? ["text", "image"] : ["text"],
|
|
49
|
+
reasoning: config.reasoning,
|
|
50
|
+
contextWindow: config.context_window,
|
|
51
|
+
maxTokens: config.max_tokens,
|
|
52
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
53
|
+
compat: { supportsDeveloperRole: false },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}));
|
|
57
|
+
return withImageLimits(models, { provider: PROVIDER_ID, modelId: config.id });
|
|
58
|
+
}
|
|
59
|
+
case "openai":
|
|
60
|
+
models.setProvider(openaiProvider());
|
|
61
|
+
break;
|
|
62
|
+
case "anthropic":
|
|
63
|
+
models.setProvider(anthropicProvider());
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
throw new ConfigError(`model.provider: unknown provider ${JSON.stringify(config)}`);
|
|
67
|
+
}
|
|
68
|
+
if (models.getModel(config.provider, config.id) === undefined) {
|
|
69
|
+
throw new ConfigError(`model.id: ${config.provider} has no model "${config.id}"`);
|
|
70
|
+
}
|
|
71
|
+
return withImageLimits(models, { provider: config.provider, modelId: config.id });
|
|
72
|
+
}
|
|
73
|
+
function withImageLimits(models, model) {
|
|
74
|
+
const found = models.getModel(model.provider, model.modelId);
|
|
75
|
+
if (found?.input.includes("image") !== true)
|
|
76
|
+
return { models, model };
|
|
77
|
+
const resize = found.inputLimits?.images?.resize;
|
|
78
|
+
return {
|
|
79
|
+
models,
|
|
80
|
+
model,
|
|
81
|
+
imageLimits: {
|
|
82
|
+
maxWidth: Math.min(resize?.maxWidth ?? Infinity, CHAT_IMAGE_LIMITS.maxWidth),
|
|
83
|
+
maxHeight: Math.min(resize?.maxHeight ?? Infinity, CHAT_IMAGE_LIMITS.maxHeight),
|
|
84
|
+
maxBytes: Math.min(resize?.maxBytes ?? Infinity, CHAT_IMAGE_LIMITS.maxBytes),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineExtension, GenerationTask, hook, section } from "@earendil-works/pi-durable";
|
|
2
|
+
import { olderImagesHidden } from "./attachments.js";
|
|
3
|
+
const PROMPT = `You are @housecall, the IT helpdesk assistant in your company's chat.
|
|
4
|
+
People message you when something on their computer or account is not working, or when they need to know how to do something.
|
|
5
|
+
|
|
6
|
+
- Keep answers short and concrete: numbered steps the person can follow, in plain language.
|
|
7
|
+
- Write Markdown: **bold**, lists, \`code\`, and [title](url) links. No tables or HTML.
|
|
8
|
+
- Never invent internal procedures, links, or settings. If you do not know, say so and suggest who to contact.
|
|
9
|
+
- Some actions need the person's approval; the chat asks them for you. Do not ask them to confirm a tool call yourself.
|
|
10
|
+
- Each thread is one conversation. Earlier messages in the thread may be included for context.`;
|
|
11
|
+
export const PromptExtension = defineExtension({
|
|
12
|
+
name: "prompt",
|
|
13
|
+
sections: [section("housecall", () => PROMPT, { tag: false })],
|
|
14
|
+
hooks: [hook(GenerationTask, { beforeRequest: ({ messages }) => ({ messages: olderImagesHidden(messages) }) })],
|
|
15
|
+
});
|
package/dist/start.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Extension } from "@earendil-works/pi-durable";
|
|
2
|
+
export type StartOptions = {
|
|
3
|
+
/** The config file, relative to the working directory. */
|
|
4
|
+
config: string;
|
|
5
|
+
/** The state directory, relative to the working directory. One process may use it at a time. */
|
|
6
|
+
data: string;
|
|
7
|
+
extensions?: Extension[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Starts @housecall and returns once it is up; on SIGINT or SIGTERM it shuts down and exits the process. `--console` in
|
|
11
|
+
* process.argv selects the console adapter.
|
|
12
|
+
*/
|
|
13
|
+
export declare function start(options: StartOptions): Promise<void>;
|