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.
@@ -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,4 @@
1
+ export { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from "./config.js";
2
+ export { McpConnectionManager } from "./client.js";
3
+ export { createMcpTools } from "./registry.js";
4
+ //# sourceMappingURL=index.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
@@ -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
- return JSON.stringify(decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })));
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 array of recent squad decisions", () => {
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 an empty JSON array for a squad with no decisions", () => {
246
+ it("returns empty decisions array for a squad with no decisions", () => {
247
247
  const snapshot = buildContextSnapshot("test-squad");
248
- assert.deepEqual(JSON.parse(snapshot), []);
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 ────────────────────────────────────────────────────────
@@ -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.31.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.3.0",
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"