heyio 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/mcp.test.js +285 -0
- package/dist/api/server.js +80 -4
- package/dist/copilot/agents.js +51 -1
- package/dist/copilot/auto-complete-instance.test.js +104 -0
- package/dist/copilot/orchestrator.js +43 -6
- package/dist/copilot/tools.js +90 -1
- package/dist/daemon.js +2 -2
- package/dist/instance-watchdog.js +43 -9
- package/dist/instance-watchdog.test.js +77 -6
- package/dist/mcp/client.js +109 -0
- package/dist/mcp/client.test.js +99 -0
- package/dist/mcp/config.js +37 -0
- package/dist/mcp/config.test.js +49 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/registry.js +96 -0
- package/dist/mcp/registry.test.js +79 -0
- package/dist/store/instances.js +9 -1
- package/dist/store/instances.test.js +26 -7
- package/dist/telegram/bot.js +175 -0
- package/dist/wiki/fs.js +11 -0
- package/dist/wiki/wiki-squad.test.js +54 -0
- package/package.json +3 -2
- package/web-dist/assets/index-Ddn6rUkk.js +88 -0
- package/web-dist/assets/index-KNbOV6QX.css +10 -0
- package/web-dist/index.html +2 -2
- package/dist/store/inbox.js +0 -28
- package/web-dist/assets/index-4dkSQDXb.js +0 -88
- package/web-dist/assets/index-DK5ySkTW.css +0 -10
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { IO_HOME } from "../paths.js";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
export const MCP_CONFIG_PATH = join(IO_HOME, "mcp.json");
|
|
6
|
+
// Mutable override for tests — mirrors the setDbPathForTests pattern.
|
|
7
|
+
let _configPath = MCP_CONFIG_PATH;
|
|
8
|
+
export function setMcpConfigPathForTests(path) {
|
|
9
|
+
_configPath = path;
|
|
10
|
+
}
|
|
11
|
+
export function resetMcpConfigPath() {
|
|
12
|
+
_configPath = MCP_CONFIG_PATH;
|
|
13
|
+
}
|
|
14
|
+
export function loadMcpConfig() {
|
|
15
|
+
if (!existsSync(_configPath)) {
|
|
16
|
+
return { servers: [] };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(_configPath, "utf-8");
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!parsed.servers || !Array.isArray(parsed.servers)) {
|
|
22
|
+
return { servers: [] };
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { servers: [] };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function saveMcpConfig(config) {
|
|
31
|
+
const dir = dirname(_configPath);
|
|
32
|
+
if (!existsSync(dir)) {
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
writeFileSync(_configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
// We need to override MCP_CONFIG_PATH for testing.
|
|
7
|
+
// Since config.ts uses IO_HOME from paths.ts, we'll test by directly
|
|
8
|
+
// exercising the functions with a mocked path approach.
|
|
9
|
+
import { loadMcpConfig, saveMcpConfig } from "./config.js";
|
|
10
|
+
describe("MCP config", () => {
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let originalConfigPath;
|
|
13
|
+
before(() => {
|
|
14
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-config-test-"));
|
|
15
|
+
});
|
|
16
|
+
after(() => {
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
it("loadMcpConfig returns empty servers when no file exists", () => {
|
|
20
|
+
// MCP_CONFIG_PATH points to ~/.io/mcp.json which may or may not exist
|
|
21
|
+
// but the function handles missing files gracefully
|
|
22
|
+
const config = loadMcpConfig();
|
|
23
|
+
assert.ok(Array.isArray(config.servers));
|
|
24
|
+
});
|
|
25
|
+
it("saveMcpConfig and loadMcpConfig round-trip correctly", () => {
|
|
26
|
+
const testConfig = {
|
|
27
|
+
servers: [
|
|
28
|
+
{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-server-figma"], enabled: true },
|
|
29
|
+
{ name: "postgres", url: "http://localhost:3001/sse", enabled: false },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
saveMcpConfig(testConfig);
|
|
33
|
+
const loaded = loadMcpConfig();
|
|
34
|
+
assert.equal(loaded.servers.length, 2);
|
|
35
|
+
assert.equal(loaded.servers[0].name, "figma");
|
|
36
|
+
assert.equal(loaded.servers[0].command, "npx");
|
|
37
|
+
assert.deepEqual(loaded.servers[0].args, ["-y", "@anthropic/mcp-server-figma"]);
|
|
38
|
+
assert.equal(loaded.servers[1].name, "postgres");
|
|
39
|
+
assert.equal(loaded.servers[1].url, "http://localhost:3001/sse");
|
|
40
|
+
assert.equal(loaded.servers[1].enabled, false);
|
|
41
|
+
});
|
|
42
|
+
it("loadMcpConfig handles malformed JSON gracefully", () => {
|
|
43
|
+
// This test verifies the catch block — in production, a corrupt file
|
|
44
|
+
// won't crash the app
|
|
45
|
+
const config = loadMcpConfig();
|
|
46
|
+
assert.ok(config.servers !== undefined);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=config.test.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defineTool } from "@github/copilot-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a name for use as a tool identifier (alphanumeric + underscore only).
|
|
5
|
+
*/
|
|
6
|
+
function sanitizeName(name) {
|
|
7
|
+
return name.replace(/[^a-zA-Z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Convert an MCP JSON Schema inputSchema to a Zod schema.
|
|
11
|
+
* Falls back to z.object({}) for passthrough if schema is missing or unparseable.
|
|
12
|
+
*/
|
|
13
|
+
function jsonSchemaToZod(inputSchema) {
|
|
14
|
+
if (!inputSchema || !inputSchema.properties) {
|
|
15
|
+
return z.object({}).passthrough();
|
|
16
|
+
}
|
|
17
|
+
const properties = inputSchema.properties;
|
|
18
|
+
const required = new Set(inputSchema.required ?? []);
|
|
19
|
+
const shape = {};
|
|
20
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
21
|
+
let field;
|
|
22
|
+
switch (prop.type) {
|
|
23
|
+
case "string":
|
|
24
|
+
field = z.string();
|
|
25
|
+
break;
|
|
26
|
+
case "number":
|
|
27
|
+
case "integer":
|
|
28
|
+
field = z.number();
|
|
29
|
+
break;
|
|
30
|
+
case "boolean":
|
|
31
|
+
field = z.boolean();
|
|
32
|
+
break;
|
|
33
|
+
case "array":
|
|
34
|
+
field = z.array(z.unknown());
|
|
35
|
+
break;
|
|
36
|
+
case "object":
|
|
37
|
+
field = z.record(z.string(), z.unknown());
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
field = z.unknown();
|
|
41
|
+
}
|
|
42
|
+
if (prop.description) {
|
|
43
|
+
field = field.describe(prop.description);
|
|
44
|
+
}
|
|
45
|
+
if (!required.has(key)) {
|
|
46
|
+
field = field.optional();
|
|
47
|
+
}
|
|
48
|
+
shape[key] = field;
|
|
49
|
+
}
|
|
50
|
+
return z.object(shape).passthrough();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create Copilot SDK tools from all enabled MCP servers.
|
|
54
|
+
* Connects to each server, lists its tools, and wraps them.
|
|
55
|
+
*/
|
|
56
|
+
export async function createMcpTools(manager, config) {
|
|
57
|
+
const entries = [];
|
|
58
|
+
const enabledServers = config.servers.filter((s) => s.enabled !== false);
|
|
59
|
+
for (const serverConfig of enabledServers) {
|
|
60
|
+
try {
|
|
61
|
+
const tools = await manager.listTools(serverConfig);
|
|
62
|
+
for (const mcpTool of tools) {
|
|
63
|
+
const toolName = `mcp_${sanitizeName(serverConfig.name)}_${sanitizeName(mcpTool.name)}`;
|
|
64
|
+
const description = mcpTool.description
|
|
65
|
+
? `[MCP: ${serverConfig.name}] ${mcpTool.description}`
|
|
66
|
+
: `[MCP: ${serverConfig.name}] ${mcpTool.name}`;
|
|
67
|
+
const parameters = jsonSchemaToZod(mcpTool.inputSchema);
|
|
68
|
+
const tool = defineTool(toolName, {
|
|
69
|
+
description,
|
|
70
|
+
skipPermission: true,
|
|
71
|
+
parameters: parameters,
|
|
72
|
+
handler: async (args) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = await manager.callTool(serverConfig, mcpTool.name, args);
|
|
75
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return `MCP tool error (${serverConfig.name}/${mcpTool.name}): ${err instanceof Error ? err.message : String(err)}`;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
entries.push({
|
|
83
|
+
serverName: serverConfig.name,
|
|
84
|
+
serverConfig,
|
|
85
|
+
mcpToolName: mcpTool.name,
|
|
86
|
+
tool: tool,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error(`[mcp] Failed to connect to server "${serverConfig.name}":`, err instanceof Error ? err.message : err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createMcpTools } from "./registry.js";
|
|
4
|
+
// Mock connection manager that returns predefined tools
|
|
5
|
+
function createMockManager(toolsMap) {
|
|
6
|
+
return {
|
|
7
|
+
listTools: async (config) => {
|
|
8
|
+
return toolsMap[config.name] ?? [];
|
|
9
|
+
},
|
|
10
|
+
callTool: async (_config, toolName, args) => {
|
|
11
|
+
return `Called ${toolName} with ${JSON.stringify(args)}`;
|
|
12
|
+
},
|
|
13
|
+
getClient: async () => { throw new Error("not needed"); },
|
|
14
|
+
disconnect: async () => { },
|
|
15
|
+
disconnectAll: async () => { },
|
|
16
|
+
isConnected: () => false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("MCP registry", () => {
|
|
20
|
+
it("creates namespaced tools from MCP server tools", async () => {
|
|
21
|
+
const manager = createMockManager({
|
|
22
|
+
figma: [
|
|
23
|
+
{ name: "get_file", description: "Get a Figma file", inputSchema: { type: "object", properties: { file_key: { type: "string", description: "Figma file key" } }, required: ["file_key"] } },
|
|
24
|
+
{ name: "list_projects", description: "List projects" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
const config = {
|
|
28
|
+
servers: [{ name: "figma", command: "npx", args: ["-y", "@anthropic/mcp-figma"], enabled: true }],
|
|
29
|
+
};
|
|
30
|
+
const entries = await createMcpTools(manager, config);
|
|
31
|
+
assert.equal(entries.length, 2);
|
|
32
|
+
assert.equal(entries[0].tool.name, "mcp_figma_get_file");
|
|
33
|
+
assert.equal(entries[1].tool.name, "mcp_figma_list_projects");
|
|
34
|
+
assert.equal(entries[0].serverName, "figma");
|
|
35
|
+
assert.equal(entries[0].mcpToolName, "get_file");
|
|
36
|
+
});
|
|
37
|
+
it("skips disabled servers", async () => {
|
|
38
|
+
const manager = createMockManager({
|
|
39
|
+
figma: [{ name: "get_file", description: "Get file" }],
|
|
40
|
+
postgres: [{ name: "query", description: "Run query" }],
|
|
41
|
+
});
|
|
42
|
+
const config = {
|
|
43
|
+
servers: [
|
|
44
|
+
{ name: "figma", command: "npx", enabled: true },
|
|
45
|
+
{ name: "postgres", command: "pg-mcp", enabled: false },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const entries = await createMcpTools(manager, config);
|
|
49
|
+
assert.equal(entries.length, 1);
|
|
50
|
+
assert.equal(entries[0].serverName, "figma");
|
|
51
|
+
});
|
|
52
|
+
it("sanitizes tool names to valid identifiers", async () => {
|
|
53
|
+
const manager = createMockManager({
|
|
54
|
+
"my-server": [{ name: "get-data.v2", description: "Get data" }],
|
|
55
|
+
});
|
|
56
|
+
const config = {
|
|
57
|
+
servers: [{ name: "my-server", command: "cmd", enabled: true }],
|
|
58
|
+
};
|
|
59
|
+
const entries = await createMcpTools(manager, config);
|
|
60
|
+
assert.equal(entries[0].tool.name, "mcp_my_server_get_data_v2");
|
|
61
|
+
});
|
|
62
|
+
it("handles server connection failure gracefully", async () => {
|
|
63
|
+
const manager = {
|
|
64
|
+
listTools: async () => { throw new Error("Connection refused"); },
|
|
65
|
+
callTool: async () => { throw new Error("not connected"); },
|
|
66
|
+
getClient: async () => { throw new Error("not connected"); },
|
|
67
|
+
disconnect: async () => { },
|
|
68
|
+
disconnectAll: async () => { },
|
|
69
|
+
isConnected: () => false,
|
|
70
|
+
};
|
|
71
|
+
const config = {
|
|
72
|
+
servers: [{ name: "broken", command: "bad-cmd", enabled: true }],
|
|
73
|
+
};
|
|
74
|
+
// Should not throw — just returns empty
|
|
75
|
+
const entries = await createMcpTools(manager, config);
|
|
76
|
+
assert.equal(entries.length, 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=registry.test.js.map
|
package/dist/store/instances.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
2
|
import { getDecisions } from "./squads.js";
|
|
3
3
|
import { worktreeExists } from "./worktrees.js";
|
|
4
|
+
import { readSquadWikiPages } from "../wiki/fs.js";
|
|
4
5
|
export function ensureInstanceTables() {
|
|
5
6
|
const db = getDb();
|
|
6
7
|
db.exec(`
|
|
@@ -108,7 +109,14 @@ export function deleteInstance(id) {
|
|
|
108
109
|
*/
|
|
109
110
|
export function buildContextSnapshot(masterSquadSlug, limit = 30) {
|
|
110
111
|
const decisions = getDecisions(masterSquadSlug, limit);
|
|
111
|
-
|
|
112
|
+
const wikiPages = readSquadWikiPages(masterSquadSlug);
|
|
113
|
+
const snapshot = {
|
|
114
|
+
decisions: decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })),
|
|
115
|
+
};
|
|
116
|
+
if (wikiPages.length > 0) {
|
|
117
|
+
snapshot.wiki = wikiPages.map(p => ({ path: p.path, content: p.content }));
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(snapshot);
|
|
112
120
|
}
|
|
113
121
|
/**
|
|
114
122
|
* Reconcile instances on startup: detect orphaned worktrees and mark stale active instances.
|
|
@@ -234,18 +234,19 @@ describe("deleteInstance", () => {
|
|
|
234
234
|
});
|
|
235
235
|
// ── buildContextSnapshot ──────────────────────────────────────────────────────
|
|
236
236
|
describe("buildContextSnapshot", () => {
|
|
237
|
-
it("returns a JSON
|
|
237
|
+
it("returns a JSON object with decisions array", () => {
|
|
238
238
|
logDecision("test-squad", "use TypeScript everywhere", "consistency");
|
|
239
239
|
logDecision("test-squad", "prefer functional style");
|
|
240
240
|
const snapshot = buildContextSnapshot("test-squad");
|
|
241
241
|
const parsed = JSON.parse(snapshot);
|
|
242
|
-
assert.ok(Array.isArray(parsed));
|
|
243
|
-
assert.equal(parsed.length, 2);
|
|
244
|
-
assert.ok(parsed.some((d) => d.decision === "use TypeScript everywhere"));
|
|
242
|
+
assert.ok(Array.isArray(parsed.decisions));
|
|
243
|
+
assert.equal(parsed.decisions.length, 2);
|
|
244
|
+
assert.ok(parsed.decisions.some((d) => d.decision === "use TypeScript everywhere"));
|
|
245
245
|
});
|
|
246
|
-
it("returns
|
|
246
|
+
it("returns empty decisions array for a squad with no decisions", () => {
|
|
247
247
|
const snapshot = buildContextSnapshot("test-squad");
|
|
248
|
-
|
|
248
|
+
const parsed = JSON.parse(snapshot);
|
|
249
|
+
assert.deepEqual(parsed.decisions, []);
|
|
249
250
|
});
|
|
250
251
|
it("respects the limit parameter", () => {
|
|
251
252
|
for (let i = 0; i < 10; i++) {
|
|
@@ -253,7 +254,25 @@ describe("buildContextSnapshot", () => {
|
|
|
253
254
|
}
|
|
254
255
|
const snapshot = buildContextSnapshot("test-squad", 5);
|
|
255
256
|
const parsed = JSON.parse(snapshot);
|
|
256
|
-
assert.equal(parsed.length, 5);
|
|
257
|
+
assert.equal(parsed.decisions.length, 5);
|
|
258
|
+
});
|
|
259
|
+
it("includes wiki pages when they exist", async () => {
|
|
260
|
+
const { writePage, deletePage } = await import("../wiki/fs.js");
|
|
261
|
+
const testSlug = `test-squad-snap-${Date.now()}`;
|
|
262
|
+
const pagePath = `pages/squads/${testSlug}/rules.md`;
|
|
263
|
+
try {
|
|
264
|
+
writePage(pagePath, "# Rules\nNo force push.");
|
|
265
|
+
logDecision(testSlug, "test decision");
|
|
266
|
+
const snapshot = buildContextSnapshot(testSlug);
|
|
267
|
+
const parsed = JSON.parse(snapshot);
|
|
268
|
+
assert.ok(parsed.wiki);
|
|
269
|
+
assert.equal(parsed.wiki.length, 1);
|
|
270
|
+
assert.equal(parsed.wiki[0].path, pagePath);
|
|
271
|
+
assert.ok(parsed.wiki[0].content.includes("No force push"));
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
deletePage(pagePath);
|
|
275
|
+
}
|
|
257
276
|
});
|
|
258
277
|
});
|
|
259
278
|
// ── reconcileInstances ────────────────────────────────────────────────────────
|
package/dist/telegram/bot.js
CHANGED
|
@@ -2,8 +2,17 @@ import { Bot } from "grammy";
|
|
|
2
2
|
import { config } from "../config.js";
|
|
3
3
|
const TELEGRAM_MAX_LENGTH = 4096;
|
|
4
4
|
const EDIT_DEBOUNCE_MS = 500;
|
|
5
|
+
const FILE_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; // 5MB
|
|
5
6
|
let bot;
|
|
6
7
|
let messageHandler;
|
|
8
|
+
async function downloadTelegramFile(botInstance, fileId) {
|
|
9
|
+
const file = await botInstance.api.getFile(fileId);
|
|
10
|
+
const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${file.file_path}`;
|
|
11
|
+
const response = await fetch(url);
|
|
12
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
13
|
+
const mimeType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
14
|
+
return { data: buffer.toString("base64"), mimeType, size: buffer.length };
|
|
15
|
+
}
|
|
7
16
|
export function setMessageHandler(handler) {
|
|
8
17
|
messageHandler = handler;
|
|
9
18
|
}
|
|
@@ -82,6 +91,172 @@ export function createBot() {
|
|
|
82
91
|
await editReply("An error occurred while processing your message.");
|
|
83
92
|
}
|
|
84
93
|
});
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Photo handler
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
bot.on("message:photo", async (ctx) => {
|
|
98
|
+
const userId = ctx.from?.id;
|
|
99
|
+
if (config.authorizedUserId && userId !== config.authorizedUserId)
|
|
100
|
+
return;
|
|
101
|
+
if (!messageHandler || !bot) {
|
|
102
|
+
console.error("[io] No message handler registered");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Telegram sends an array of sizes — last element is the highest resolution
|
|
106
|
+
const photos = ctx.message.photo;
|
|
107
|
+
const photo = photos[photos.length - 1];
|
|
108
|
+
const chatId = ctx.chat.id;
|
|
109
|
+
const messageId = ctx.message.message_id;
|
|
110
|
+
const caption = ctx.message.caption ?? "";
|
|
111
|
+
await ctx.replyWithChatAction("typing");
|
|
112
|
+
const ack = await ctx.reply("📎 Processing attachment…");
|
|
113
|
+
try {
|
|
114
|
+
const { data, mimeType, size } = await downloadTelegramFile(bot, photo.file_id);
|
|
115
|
+
if (size > FILE_SIZE_LIMIT_BYTES) {
|
|
116
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const attachment = { type: "blob", data, mimeType, displayName: "photo.jpg" };
|
|
120
|
+
const placeholder = await ctx.reply("…");
|
|
121
|
+
let accumulated = "";
|
|
122
|
+
let lastEditTime = 0;
|
|
123
|
+
let pendingEdit;
|
|
124
|
+
const editReply = async (content) => {
|
|
125
|
+
try {
|
|
126
|
+
const truncated = content.length > TELEGRAM_MAX_LENGTH
|
|
127
|
+
? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
|
|
128
|
+
: content;
|
|
129
|
+
await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
if (!message.includes("message is not modified")) {
|
|
134
|
+
console.error("[io] Failed to edit message:", message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
await ctx.api.deleteMessage(chatId, ack.message_id);
|
|
139
|
+
await messageHandler(caption, chatId, messageId, (chunk, done) => {
|
|
140
|
+
accumulated += chunk;
|
|
141
|
+
if (done) {
|
|
142
|
+
if (pendingEdit) {
|
|
143
|
+
clearTimeout(pendingEdit);
|
|
144
|
+
pendingEdit = undefined;
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const timeSinceLastEdit = now - lastEditTime;
|
|
150
|
+
if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
|
|
151
|
+
lastEditTime = now;
|
|
152
|
+
void editReply(accumulated);
|
|
153
|
+
}
|
|
154
|
+
else if (!pendingEdit) {
|
|
155
|
+
pendingEdit = setTimeout(() => {
|
|
156
|
+
pendingEdit = undefined;
|
|
157
|
+
lastEditTime = Date.now();
|
|
158
|
+
void editReply(accumulated);
|
|
159
|
+
}, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
|
|
160
|
+
}
|
|
161
|
+
}, [attachment]);
|
|
162
|
+
if (pendingEdit)
|
|
163
|
+
clearTimeout(pendingEdit);
|
|
164
|
+
if (accumulated.length > 0)
|
|
165
|
+
await editReply(accumulated);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
+
console.error("[io] Error handling photo:", message);
|
|
170
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Document handler
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
bot.on("message:document", async (ctx) => {
|
|
177
|
+
const userId = ctx.from?.id;
|
|
178
|
+
if (config.authorizedUserId && userId !== config.authorizedUserId)
|
|
179
|
+
return;
|
|
180
|
+
if (!messageHandler || !bot) {
|
|
181
|
+
console.error("[io] No message handler registered");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const doc = ctx.message.document;
|
|
185
|
+
const chatId = ctx.chat.id;
|
|
186
|
+
const messageId = ctx.message.message_id;
|
|
187
|
+
const caption = ctx.message.caption ?? "";
|
|
188
|
+
// Reject oversized files before downloading (file_size may be undefined for large files)
|
|
189
|
+
if (doc.file_size !== undefined && doc.file_size > FILE_SIZE_LIMIT_BYTES) {
|
|
190
|
+
await ctx.reply("⚠️ File too large (max 5MB). Attachment not processed.");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await ctx.replyWithChatAction("typing");
|
|
194
|
+
const ack = await ctx.reply("📎 Processing attachment…");
|
|
195
|
+
try {
|
|
196
|
+
const { data, mimeType, size } = await downloadTelegramFile(bot, doc.file_id);
|
|
197
|
+
if (size > FILE_SIZE_LIMIT_BYTES) {
|
|
198
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const attachment = {
|
|
202
|
+
type: "blob",
|
|
203
|
+
data,
|
|
204
|
+
mimeType,
|
|
205
|
+
displayName: doc.file_name ?? "document",
|
|
206
|
+
};
|
|
207
|
+
const placeholder = await ctx.reply("…");
|
|
208
|
+
let accumulated = "";
|
|
209
|
+
let lastEditTime = 0;
|
|
210
|
+
let pendingEdit;
|
|
211
|
+
const editReply = async (content) => {
|
|
212
|
+
try {
|
|
213
|
+
const truncated = content.length > TELEGRAM_MAX_LENGTH
|
|
214
|
+
? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
|
|
215
|
+
: content;
|
|
216
|
+
await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
if (!message.includes("message is not modified")) {
|
|
221
|
+
console.error("[io] Failed to edit message:", message);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
await ctx.api.deleteMessage(chatId, ack.message_id);
|
|
226
|
+
await messageHandler(caption, chatId, messageId, (chunk, done) => {
|
|
227
|
+
accumulated += chunk;
|
|
228
|
+
if (done) {
|
|
229
|
+
if (pendingEdit) {
|
|
230
|
+
clearTimeout(pendingEdit);
|
|
231
|
+
pendingEdit = undefined;
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const timeSinceLastEdit = now - lastEditTime;
|
|
237
|
+
if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
|
|
238
|
+
lastEditTime = now;
|
|
239
|
+
void editReply(accumulated);
|
|
240
|
+
}
|
|
241
|
+
else if (!pendingEdit) {
|
|
242
|
+
pendingEdit = setTimeout(() => {
|
|
243
|
+
pendingEdit = undefined;
|
|
244
|
+
lastEditTime = Date.now();
|
|
245
|
+
void editReply(accumulated);
|
|
246
|
+
}, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
|
|
247
|
+
}
|
|
248
|
+
}, [attachment]);
|
|
249
|
+
if (pendingEdit)
|
|
250
|
+
clearTimeout(pendingEdit);
|
|
251
|
+
if (accumulated.length > 0)
|
|
252
|
+
await editReply(accumulated);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
console.error("[io] Error handling document:", message);
|
|
257
|
+
await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
85
260
|
bot.catch((err) => {
|
|
86
261
|
console.error("[io] Grammy bot error:", err.message);
|
|
87
262
|
});
|
package/dist/wiki/fs.js
CHANGED
|
@@ -149,4 +149,15 @@ export function writeLogFile(content) {
|
|
|
149
149
|
export function getWikiDir() {
|
|
150
150
|
return WIKI_DIR;
|
|
151
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Read all wiki pages for a squad by slug.
|
|
154
|
+
* Returns array of { path, content } for pages under pages/squads/{slug}/.
|
|
155
|
+
*/
|
|
156
|
+
export function readSquadWikiPages(slug) {
|
|
157
|
+
const prefix = `pages/squads/${slug}/`;
|
|
158
|
+
return listPages()
|
|
159
|
+
.filter(p => p.startsWith(prefix))
|
|
160
|
+
.map(p => ({ path: p, content: readPage(p) ?? "" }))
|
|
161
|
+
.filter(entry => entry.content.length > 0);
|
|
162
|
+
}
|
|
152
163
|
//# sourceMappingURL=fs.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readSquadWikiPages, writePage, deletePage } from "./fs.js";
|
|
4
|
+
describe("readSquadWikiPages", () => {
|
|
5
|
+
it("returns empty array for non-existent squad", () => {
|
|
6
|
+
const pages = readSquadWikiPages("nonexistent-squad-xyz");
|
|
7
|
+
assert.ok(Array.isArray(pages));
|
|
8
|
+
assert.equal(pages.length, 0);
|
|
9
|
+
});
|
|
10
|
+
it("returns pages under pages/squads/{slug}/ prefix", () => {
|
|
11
|
+
const testSlug = `test-squad-${Date.now()}`;
|
|
12
|
+
const pagePath = `pages/squads/${testSlug}/workflow.md`;
|
|
13
|
+
try {
|
|
14
|
+
writePage(pagePath, "# Workflow Rules\nAlways use feature branches.");
|
|
15
|
+
const pages = readSquadWikiPages(testSlug);
|
|
16
|
+
assert.equal(pages.length, 1);
|
|
17
|
+
assert.equal(pages[0].path, pagePath);
|
|
18
|
+
assert.ok(pages[0].content.includes("feature branches"));
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
deletePage(pagePath);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
it("filters out empty pages", () => {
|
|
25
|
+
const testSlug = `test-squad-empty-${Date.now()}`;
|
|
26
|
+
const pagePath = `pages/squads/${testSlug}/empty.md`;
|
|
27
|
+
try {
|
|
28
|
+
writePage(pagePath, "");
|
|
29
|
+
const pages = readSquadWikiPages(testSlug);
|
|
30
|
+
assert.equal(pages.length, 0);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
deletePage(pagePath);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it("returns multiple pages for a squad", () => {
|
|
37
|
+
const testSlug = `test-squad-multi-${Date.now()}`;
|
|
38
|
+
const page1 = `pages/squads/${testSlug}/workflow.md`;
|
|
39
|
+
const page2 = `pages/squads/${testSlug}/coding-standards.md`;
|
|
40
|
+
try {
|
|
41
|
+
writePage(page1, "# Workflow\nUse PRs.");
|
|
42
|
+
writePage(page2, "# Standards\nESLint required.");
|
|
43
|
+
const pages = readSquadWikiPages(testSlug);
|
|
44
|
+
assert.equal(pages.length, 2);
|
|
45
|
+
const paths = pages.map(p => p.path).sort();
|
|
46
|
+
assert.deepEqual(paths, [page2, page1].sort());
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
deletePage(page1);
|
|
50
|
+
deletePage(page2);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=wiki-squad.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heyio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
|
|
5
5
|
"bin": {
|
|
6
6
|
"io": "dist/index.js"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"homepage": "https://github.com/michaeljolley/io#readme",
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@github/copilot-sdk": "^0.2.2",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
46
|
"better-sqlite3": "^12.6.2",
|
|
46
47
|
"commander": "^14.0.0",
|
|
47
48
|
"dotenv": "^17.3.1",
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
54
55
|
"@types/express": "^5.0.6",
|
|
55
|
-
"@types/node": "^25.
|
|
56
|
+
"@types/node": "^25.9.1",
|
|
56
57
|
"tsx": "^4.21.0",
|
|
57
58
|
"typescript": "^5.9.3",
|
|
58
59
|
"vue-tsc": "^3.2.9"
|