heyio 0.1.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,354 @@
1
+ import { approveAll, } from "@github/copilot-sdk";
2
+ import { config } from "../config.js";
3
+ import { SESSIONS_DIR } from "../paths.js";
4
+ import { getState, setState, deleteState, logConversation } from "../store/db.js";
5
+ import { clearStaleTasks } from "../store/tasks.js";
6
+ import { getSquad, listSquads, createSquad, logDecision, getDecisionsSummary, updateSquadStatus, } from "../store/squads.js";
7
+ import { readPage, writePage, assertPagePath } from "../wiki/fs.js";
8
+ import { searchWiki, getWikiSummary } from "../wiki/search.js";
9
+ import { getOrchestratorSystemMessage } from "./system-message.js";
10
+ import { createTools } from "./tools.js";
11
+ import { getSkillDirectories } from "./skills.js";
12
+ import { resetClient } from "./client.js";
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+ const HEALTH_CHECK_INTERVAL_MS = 30_000;
17
+ const SEND_TIMEOUT_MS = 600_000;
18
+ const MAX_RETRIES = 3;
19
+ const SESSION_ID_KEY = "orchestrator_session_id";
20
+ // ---------------------------------------------------------------------------
21
+ // Module state
22
+ // ---------------------------------------------------------------------------
23
+ let client;
24
+ let orchestratorSession;
25
+ let healthCheckTimer;
26
+ let sessionInitPromise;
27
+ let clientResetPromise;
28
+ const messageQueue = [];
29
+ let processing = false;
30
+ // ---------------------------------------------------------------------------
31
+ // Session config helpers
32
+ // ---------------------------------------------------------------------------
33
+ function mapSquad(s) {
34
+ return { slug: s.slug, name: s.name, projectPath: s.project_path, status: s.status };
35
+ }
36
+ function getToolDeps() {
37
+ return {
38
+ wikiRead: readPage,
39
+ wikiWrite: writePage,
40
+ wikiSearch: searchWiki,
41
+ wikiAssertPagePath: assertPagePath,
42
+ getSquad: (slug) => {
43
+ const s = getSquad(slug);
44
+ return s ? mapSquad(s) : undefined;
45
+ },
46
+ listSquads: () => listSquads().map(mapSquad),
47
+ createSquad,
48
+ logDecision,
49
+ getDecisionsSummary,
50
+ updateSquadStatus,
51
+ };
52
+ }
53
+ function getSessionConfig() {
54
+ const tools = createTools(getToolDeps());
55
+ return { tools, skillDirectories: getSkillDirectories() };
56
+ }
57
+ function buildFullSessionConfig() {
58
+ const { tools, skillDirectories } = getSessionConfig();
59
+ return {
60
+ model: config.defaultModel || "gpt-4.1",
61
+ configDir: SESSIONS_DIR,
62
+ streaming: true,
63
+ systemMessage: {
64
+ content: getOrchestratorSystemMessage({
65
+ selfEditEnabled: config.selfEditEnabled,
66
+ memorySummary: getWikiSummary() || undefined,
67
+ }),
68
+ },
69
+ tools,
70
+ skillDirectories,
71
+ onPermissionRequest: approveAll,
72
+ infiniteSessions: {
73
+ enabled: true,
74
+ backgroundCompactionThreshold: 0.80,
75
+ bufferExhaustionThreshold: 0.95,
76
+ },
77
+ };
78
+ }
79
+ function buildResumeConfig() {
80
+ const { tools, skillDirectories } = getSessionConfig();
81
+ return {
82
+ configDir: SESSIONS_DIR,
83
+ streaming: true,
84
+ tools,
85
+ skillDirectories,
86
+ onPermissionRequest: approveAll,
87
+ infiniteSessions: {
88
+ enabled: true,
89
+ backgroundCompactionThreshold: 0.80,
90
+ bufferExhaustionThreshold: 0.95,
91
+ },
92
+ };
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Error classification
96
+ // ---------------------------------------------------------------------------
97
+ function isConnectionError(err) {
98
+ if (!(err instanceof Error))
99
+ return false;
100
+ const msg = err.message.toLowerCase();
101
+ return (msg.includes("disconnect") ||
102
+ msg.includes("epipe") ||
103
+ msg.includes("econnreset") ||
104
+ msg.includes("econnrefused") ||
105
+ msg.includes("connection") ||
106
+ msg.includes("socket"));
107
+ }
108
+ function isSessionError(err) {
109
+ if (!(err instanceof Error))
110
+ return false;
111
+ const msg = err.message.toLowerCase();
112
+ return (msg.includes("session") ||
113
+ msg.includes("closed") ||
114
+ msg.includes("expired") ||
115
+ msg.includes("not found"));
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Client management
119
+ // ---------------------------------------------------------------------------
120
+ async function ensureClient() {
121
+ if (!client) {
122
+ throw new Error("Orchestrator not initialized — call initOrchestrator first");
123
+ }
124
+ if (client.getState() === "connected")
125
+ return client;
126
+ // Coalesce concurrent reset attempts
127
+ if (clientResetPromise)
128
+ return clientResetPromise;
129
+ clientResetPromise = (async () => {
130
+ console.error("[io] Client disconnected, resetting…");
131
+ try {
132
+ const newClient = await resetClient();
133
+ client = newClient;
134
+ return newClient;
135
+ }
136
+ finally {
137
+ clientResetPromise = undefined;
138
+ }
139
+ })();
140
+ return clientResetPromise;
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Session management
144
+ // ---------------------------------------------------------------------------
145
+ async function ensureOrchestratorSession() {
146
+ if (orchestratorSession)
147
+ return orchestratorSession;
148
+ // Coalesce concurrent session creation
149
+ if (sessionInitPromise)
150
+ return sessionInitPromise;
151
+ sessionInitPromise = (async () => {
152
+ try {
153
+ const c = await ensureClient();
154
+ const savedSessionId = getState(SESSION_ID_KEY);
155
+ if (savedSessionId) {
156
+ try {
157
+ console.error("[io] Resuming session:", savedSessionId);
158
+ const session = await c.resumeSession(savedSessionId, buildResumeConfig());
159
+ orchestratorSession = session;
160
+ return session;
161
+ }
162
+ catch (err) {
163
+ console.error("[io] Failed to resume session, creating new one:", err instanceof Error ? err.message : err);
164
+ deleteState(SESSION_ID_KEY);
165
+ }
166
+ }
167
+ console.error("[io] Creating new orchestrator session");
168
+ const session = await c.createSession(buildFullSessionConfig());
169
+ setState(SESSION_ID_KEY, session.sessionId);
170
+ orchestratorSession = session;
171
+ return session;
172
+ }
173
+ finally {
174
+ sessionInitPromise = undefined;
175
+ }
176
+ })();
177
+ return sessionInitPromise;
178
+ }
179
+ function invalidateSession() {
180
+ orchestratorSession = undefined;
181
+ sessionInitPromise = undefined;
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Message execution
185
+ // ---------------------------------------------------------------------------
186
+ async function executeOnSession(prompt, callback) {
187
+ const session = await ensureOrchestratorSession();
188
+ let accumulated = "";
189
+ const unsubDelta = session.on("assistant.message_delta", (event) => {
190
+ accumulated += event.data.deltaContent;
191
+ callback(accumulated, false);
192
+ });
193
+ try {
194
+ const result = await session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
195
+ unsubDelta();
196
+ const finalText = result?.data.content ?? accumulated;
197
+ callback(finalText, true);
198
+ return finalText;
199
+ }
200
+ catch (err) {
201
+ unsubDelta();
202
+ // If we accumulated partial text, return it gracefully on timeout
203
+ if (accumulated && err instanceof Error && err.message.includes("timeout")) {
204
+ console.error("[io] Session sendAndWait timed out, returning partial response");
205
+ callback(accumulated, true);
206
+ return accumulated;
207
+ }
208
+ // Session-level errors: invalidate and let caller retry
209
+ if (isSessionError(err)) {
210
+ console.error("[io] Session error, invalidating:", err instanceof Error ? err.message : err);
211
+ invalidateSession();
212
+ }
213
+ throw err;
214
+ }
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // Queue processing
218
+ // ---------------------------------------------------------------------------
219
+ function sourceTag(source) {
220
+ switch (source.type) {
221
+ case "telegram":
222
+ return "[via telegram]";
223
+ case "tui":
224
+ return "[via tui]";
225
+ case "background":
226
+ return "[via background]";
227
+ }
228
+ }
229
+ function sourceLabel(source) {
230
+ switch (source.type) {
231
+ case "telegram":
232
+ return "telegram";
233
+ case "tui":
234
+ return "tui";
235
+ case "background":
236
+ return "background";
237
+ }
238
+ }
239
+ async function processQueue() {
240
+ if (processing)
241
+ return;
242
+ processing = true;
243
+ try {
244
+ while (messageQueue.length > 0) {
245
+ const msg = messageQueue.shift();
246
+ const taggedPrompt = `${sourceTag(msg.source)} ${msg.prompt}`;
247
+ let lastError;
248
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
249
+ try {
250
+ const response = await executeOnSession(taggedPrompt, msg.callback);
251
+ logConversation("assistant", response, sourceLabel(msg.source));
252
+ msg.resolve();
253
+ lastError = undefined;
254
+ break;
255
+ }
256
+ catch (err) {
257
+ lastError = err instanceof Error ? err : new Error(String(err));
258
+ console.error(`[io] Attempt ${attempt}/${MAX_RETRIES} failed:`, lastError.message);
259
+ if (isConnectionError(err)) {
260
+ // Reset client and invalidate session for connection errors
261
+ invalidateSession();
262
+ try {
263
+ await ensureClient();
264
+ }
265
+ catch (resetErr) {
266
+ console.error("[io] Client reset failed:", resetErr instanceof Error ? resetErr.message : resetErr);
267
+ }
268
+ }
269
+ else if (isSessionError(err)) {
270
+ // Session already invalidated in executeOnSession
271
+ }
272
+ else if (attempt === MAX_RETRIES) {
273
+ // Non-retryable error on last attempt
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ if (lastError) {
279
+ const errorMsg = `Sorry, I encountered an error: ${lastError.message}`;
280
+ msg.callback(errorMsg, true);
281
+ msg.reject(lastError);
282
+ }
283
+ }
284
+ }
285
+ finally {
286
+ processing = false;
287
+ }
288
+ }
289
+ // ---------------------------------------------------------------------------
290
+ // Public API
291
+ // ---------------------------------------------------------------------------
292
+ export async function initOrchestrator(copilotClient) {
293
+ client = copilotClient;
294
+ clearStaleTasks();
295
+ // Validate the configured model
296
+ try {
297
+ const models = await copilotClient.listModels();
298
+ const defaultModel = config.defaultModel || "gpt-4.1";
299
+ const modelIds = models.map((m) => m.id);
300
+ if (!modelIds.includes(defaultModel)) {
301
+ console.error(`[io] Configured model "${defaultModel}" not found. Available: ${modelIds.join(", ")}`);
302
+ }
303
+ else {
304
+ console.error(`[io] Model validated: ${defaultModel}`);
305
+ }
306
+ }
307
+ catch (err) {
308
+ console.error("[io] Could not validate models:", err instanceof Error ? err.message : err);
309
+ }
310
+ // Start health check timer
311
+ healthCheckTimer = setInterval(() => {
312
+ if (!client || client.getState() !== "connected") {
313
+ console.error("[io] Health check: client disconnected, reconnecting…");
314
+ ensureClient().catch((err) => {
315
+ console.error("[io] Health check reconnect failed:", err instanceof Error ? err.message : err);
316
+ });
317
+ invalidateSession();
318
+ }
319
+ }, HEALTH_CHECK_INTERVAL_MS);
320
+ // Eagerly create/resume the session
321
+ try {
322
+ await ensureOrchestratorSession();
323
+ console.error("[io] Orchestrator session ready");
324
+ }
325
+ catch (err) {
326
+ console.error("[io] Eager session creation failed (will retry on first message):", err instanceof Error ? err.message : err);
327
+ }
328
+ }
329
+ export async function sendToOrchestrator(prompt, source, callback) {
330
+ logConversation("user", prompt, sourceLabel(source));
331
+ return new Promise((resolve, reject) => {
332
+ messageQueue.push({ prompt, source, callback, resolve, reject });
333
+ processQueue();
334
+ });
335
+ }
336
+ export async function shutdownOrchestrator() {
337
+ if (healthCheckTimer) {
338
+ clearInterval(healthCheckTimer);
339
+ healthCheckTimer = undefined;
340
+ }
341
+ if (orchestratorSession) {
342
+ try {
343
+ await orchestratorSession.disconnect();
344
+ }
345
+ catch (err) {
346
+ console.error("[io] Error disconnecting session:", err instanceof Error ? err.message : err);
347
+ }
348
+ orchestratorSession = undefined;
349
+ }
350
+ sessionInitPromise = undefined;
351
+ clientResetPromise = undefined;
352
+ client = undefined;
353
+ }
354
+ //# sourceMappingURL=orchestrator.js.map
@@ -0,0 +1,132 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { execSync } from "child_process";
4
+ import { SKILLS_DIR } from "../paths.js";
5
+ /**
6
+ * Scan SKILLS_DIR for subdirectories that contain a SKILL.md file.
7
+ * Returns absolute paths to qualifying skill directories.
8
+ */
9
+ export function getSkillDirectories() {
10
+ if (!existsSync(SKILLS_DIR))
11
+ return [];
12
+ const dirs = [];
13
+ for (const entry of readdirSync(SKILLS_DIR)) {
14
+ const skillDir = join(SKILLS_DIR, entry);
15
+ if (!statSync(skillDir).isDirectory())
16
+ continue;
17
+ const skillMd = join(skillDir, "SKILL.md");
18
+ if (!existsSync(skillMd))
19
+ continue;
20
+ dirs.push(skillDir);
21
+ // Check for an agents subdirectory (Copilot SDK custom agents)
22
+ const agentsDir = join(skillDir, "agents");
23
+ if (existsSync(agentsDir) && statSync(agentsDir).isDirectory()) {
24
+ dirs.push(agentsDir);
25
+ }
26
+ }
27
+ return dirs;
28
+ }
29
+ function parseSkillMd(content) {
30
+ const lines = content.split(/\r?\n/);
31
+ let name = "";
32
+ let description = "";
33
+ let foundHeading = false;
34
+ const descLines = [];
35
+ for (const line of lines) {
36
+ if (!foundHeading) {
37
+ const match = line.match(/^#\s+(.+)/);
38
+ if (match) {
39
+ name = match[1].trim();
40
+ foundHeading = true;
41
+ }
42
+ continue;
43
+ }
44
+ // Skip blank lines between heading and first paragraph
45
+ if (descLines.length === 0 && line.trim() === "")
46
+ continue;
47
+ // Stop at the next blank line after collecting description text
48
+ if (descLines.length > 0 && line.trim() === "")
49
+ break;
50
+ // Stop at another heading
51
+ if (line.match(/^#+\s/))
52
+ break;
53
+ descLines.push(line.trim());
54
+ }
55
+ description = descLines.join(" ");
56
+ return { name, description };
57
+ }
58
+ /**
59
+ * List all installed skills with metadata parsed from their SKILL.md files.
60
+ */
61
+ export function listSkills() {
62
+ if (!existsSync(SKILLS_DIR))
63
+ return [];
64
+ const skills = [];
65
+ for (const entry of readdirSync(SKILLS_DIR)) {
66
+ const skillDir = join(SKILLS_DIR, entry);
67
+ if (!statSync(skillDir).isDirectory())
68
+ continue;
69
+ const skillMdPath = join(skillDir, "SKILL.md");
70
+ if (!existsSync(skillMdPath))
71
+ continue;
72
+ const content = readFileSync(skillMdPath, "utf-8");
73
+ const { name, description } = parseSkillMd(content);
74
+ skills.push({
75
+ name: name || entry,
76
+ slug: entry,
77
+ description,
78
+ path: skillDir,
79
+ });
80
+ }
81
+ return skills;
82
+ }
83
+ /**
84
+ * Clone a git repo into SKILLS_DIR and return the installed skill info.
85
+ * Throws if the cloned repo does not contain a SKILL.md file.
86
+ */
87
+ export async function installSkill(repoUrl) {
88
+ const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
89
+ const destDir = join(SKILLS_DIR, repoName);
90
+ execSync(`git clone ${repoUrl} ${destDir}`, { stdio: "pipe" });
91
+ const skillMdPath = join(destDir, "SKILL.md");
92
+ if (!existsSync(skillMdPath)) {
93
+ rmSync(destDir, { recursive: true, force: true });
94
+ throw new Error(`Repository "${repoUrl}" does not contain a SKILL.md file.`);
95
+ }
96
+ const content = readFileSync(skillMdPath, "utf-8");
97
+ const { name, description } = parseSkillMd(content);
98
+ return {
99
+ name: name || repoName,
100
+ slug: repoName,
101
+ description,
102
+ path: destDir,
103
+ };
104
+ }
105
+ /**
106
+ * Remove a skill directory by its slug. Returns true if it existed.
107
+ */
108
+ export function removeSkill(slug) {
109
+ const skillDir = join(SKILLS_DIR, slug);
110
+ if (!existsSync(skillDir))
111
+ return false;
112
+ rmSync(skillDir, { recursive: true, force: true });
113
+ return true;
114
+ }
115
+ /**
116
+ * Search the skills registry for skills matching the given query.
117
+ * Returns an empty array on network or parsing errors.
118
+ */
119
+ export async function searchSkillsRegistry(query) {
120
+ try {
121
+ const url = `https://skills.sh/api/search?q=${encodeURIComponent(query)}`;
122
+ const response = await fetch(url);
123
+ if (!response.ok)
124
+ return [];
125
+ const data = (await response.json());
126
+ return Array.isArray(data) ? data : [];
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ }
132
+ //# sourceMappingURL=skills.js.map
@@ -0,0 +1,90 @@
1
+ import { config } from "../config.js";
2
+ export function getOrchestratorSystemMessage(opts) {
3
+ const memoryBlock = opts?.memorySummary
4
+ ? `\n## Memory\nYou have a persistent knowledge base. Here's what you currently know:\n\n${opts.memorySummary}\n`
5
+ : "\n## Memory\nYou have a persistent knowledge base (wiki). It's currently empty — use `wiki_write` to start building it!\n";
6
+ const selfEditBlock = opts?.selfEditEnabled
7
+ ? ""
8
+ : `\n## Self-Edit Protection
9
+
10
+ **You must NEVER modify your own source code.** This includes the IO codebase, configuration files in the project repo, or any file that is part of the IO application itself.
11
+
12
+ If the user asks you to modify your own code, politely decline and explain that self-editing is disabled for safety. Suggest they make the changes manually or start IO with \`--self-edit\` to temporarily allow it.
13
+
14
+ This restriction does NOT apply to:
15
+ - User project files (code the user asks you to work on)
16
+ - Skills in ~/.io/skills/ (user data)
17
+ - The ~/.io/config.json file
18
+ - Any files outside the IO installation directory
19
+ `;
20
+ const squadBlock = opts?.squadRoster
21
+ ? `\n### Active Squads\n${opts.squadRoster}\n`
22
+ : "";
23
+ const osName = process.platform === "darwin" ? "macOS"
24
+ : process.platform === "win32" ? "Windows"
25
+ : "Linux";
26
+ return `You are IO, a personal AI assistant for developers running 24/7 on the user's machine (${osName}). You are an always-on assistant daemon.
27
+
28
+ ## Your Architecture
29
+
30
+ You are a Node.js daemon process built with the Copilot SDK. Here's how you work:
31
+
32
+ - **Telegram bot**: Messages arrive tagged with \`[via telegram]\`. Keep responses concise and mobile-friendly.
33
+ - **Local TUI**: A terminal interface on the local machine. Messages arrive tagged with \`[via tui]\`. You can be more verbose here.
34
+ - **Background tasks**: Messages tagged \`[via background]\` are results from squad workers you delegated to.
35
+ - **HTTP API**: You expose a local API on port ${config.apiPort} for programmatic access.
36
+
37
+ When no source tag is present, assume TUI.
38
+
39
+ ## Your Capabilities
40
+
41
+ 1. **Direct conversation**: Answer questions, discuss problems — no tools needed.
42
+ 2. **Squad system**: You can create project squads — persistent teams of specialized agents for specific projects. Each squad remembers its decisions and context.
43
+ 3. **Knowledge base**: You have a wiki-style knowledge base. Proactively save user preferences, project details, and important facts.
44
+ 4. **Shell access**: You can run shell commands on the user's machine.
45
+ 5. **Skills**: You have a modular skill system. Skills teach you how to use external tools.
46
+
47
+ ## Your Role
48
+
49
+ You receive messages and decide how to handle them:
50
+
51
+ - **Direct answer**: For simple questions, general knowledge, status checks — answer directly.
52
+ - **Use tools**: For tasks requiring shell access, file operations, web lookups — use your tools.
53
+ - **Create/delegate to squad**: For coding projects that need persistent context — create a squad with specialized agents.
54
+ - **Use a skill**: If you have a skill for the task, use it.
55
+ ${squadBlock}
56
+ ## Squad System
57
+
58
+ Squads are persistent project teams. When a user works on a codebase:
59
+ 1. Create a squad with \`squad_create\` — this sets up a persistent team for that project.
60
+ 2. The squad remembers decisions via \`squad_log_decision\`.
61
+ 3. Recall squad context with \`squad_recall\` before doing project work.
62
+ 4. Check squad status with \`squad_status\`.
63
+
64
+ ## Tool Usage
65
+
66
+ ### Knowledge Base
67
+ - \`wiki_read\`: Read a page from your knowledge base.
68
+ - \`wiki_write\`: Write or update a page. Use for preferences, project notes, facts.
69
+ - \`wiki_search\`: Search your knowledge base.
70
+
71
+ ### Squad Management
72
+ - \`squad_create\`: Create a project squad.
73
+ - \`squad_recall\`: Get a squad's context and decisions.
74
+ - \`squad_status\`: Check squad status.
75
+ - \`squad_log_decision\`: Log a decision for a squad.
76
+
77
+ ### System
78
+ - \`shell\`: Run a shell command.
79
+ - \`web_fetch\`: Fetch a URL and return content.
80
+
81
+ ## Guidelines
82
+
83
+ 1. **Adapt to the channel**: On Telegram, be brief. On TUI, be detailed.
84
+ 2. **Proactive knowledge building**: When the user shares preferences or project details, save them to your wiki.
85
+ 3. Be conversational and helpful. You're IO.
86
+ 4. When a task fails, report the error clearly and suggest next steps.
87
+ 5. Expand shorthand paths: "~/dev/myapp" → the user's home directory + path.
88
+ ${selfEditBlock}${memoryBlock}`;
89
+ }
90
+ //# sourceMappingURL=system-message.js.map