skyloom 1.24.0 → 1.26.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.
Files changed (66) hide show
  1. package/.env.example +90 -0
  2. package/.github/workflows/ci.yml +3 -0
  3. package/CONVERSION_PLAN.md +5 -0
  4. package/LICENSE +21 -0
  5. package/README.md +4 -4
  6. package/dist/cli/main.js +2 -22
  7. package/dist/cli/main.js.map +1 -1
  8. package/dist/cli/tui.d.ts.map +1 -1
  9. package/dist/cli/tui.js +0 -6
  10. package/dist/cli/tui.js.map +1 -1
  11. package/dist/core/diff.d.ts.map +1 -1
  12. package/dist/core/diff.js +0 -1
  13. package/dist/core/diff.js.map +1 -1
  14. package/dist/core/evolve.d.ts.map +1 -1
  15. package/dist/core/evolve.js +0 -9
  16. package/dist/core/evolve.js.map +1 -1
  17. package/dist/core/graph.d.ts +1 -1
  18. package/dist/core/graph.d.ts.map +1 -1
  19. package/dist/core/graph.js +1 -1
  20. package/dist/core/graph.js.map +1 -1
  21. package/dist/core/llm.d.ts +3 -0
  22. package/dist/core/llm.d.ts.map +1 -1
  23. package/dist/core/llm.js +4 -27
  24. package/dist/core/llm.js.map +1 -1
  25. package/dist/core/mcp.d.ts +25 -1
  26. package/dist/core/mcp.d.ts.map +1 -1
  27. package/dist/core/mcp.js +175 -8
  28. package/dist/core/mcp.js.map +1 -1
  29. package/dist/core/sandbox.d.ts.map +1 -1
  30. package/dist/core/sandbox.js +0 -2
  31. package/dist/core/sandbox.js.map +1 -1
  32. package/dist/core/schemas.d.ts +1 -1
  33. package/dist/core/schemas.d.ts.map +1 -1
  34. package/dist/core/schemas.js +1 -23
  35. package/dist/core/schemas.js.map +1 -1
  36. package/dist/core/skill.d.ts.map +1 -1
  37. package/dist/core/skill.js +0 -1
  38. package/dist/core/skill.js.map +1 -1
  39. package/dist/gateway/qr.d.ts.map +1 -1
  40. package/dist/gateway/qr.js +0 -1
  41. package/dist/gateway/qr.js.map +1 -1
  42. package/dist/tools/computer.d.ts.map +1 -1
  43. package/dist/tools/computer.js.map +1 -1
  44. package/dist/web/server.d.ts.map +1 -1
  45. package/dist/web/server.js +0 -2
  46. package/dist/web/server.js.map +1 -1
  47. package/eslint.config.js +62 -0
  48. package/package.json +1 -1
  49. package/src/cli/main.ts +3 -22
  50. package/src/cli/tui.ts +0 -2
  51. package/src/core/diff.ts +0 -1
  52. package/src/core/evolve.ts +0 -6
  53. package/src/core/graph.ts +1 -1
  54. package/src/core/llm.ts +4 -33
  55. package/src/core/mcp.ts +185 -8
  56. package/src/core/sandbox.ts +1 -4
  57. package/src/core/schemas.ts +1 -25
  58. package/src/core/skill.ts +0 -1
  59. package/src/gateway/qr.ts +0 -1
  60. package/src/tools/computer.ts +2 -2
  61. package/src/web/server.ts +0 -3
  62. package/tests/factory.test.ts +56 -0
  63. package/tests/mcp_sse.test.ts +112 -0
  64. package/tests/pipelines.test.ts +118 -0
  65. package/tests/skill.test.ts +6 -5
  66. package/skill-test-ty2fOA/test.md +0 -10
@@ -5,11 +5,11 @@
5
5
  * processes and services, and install/uninstall software.
6
6
  */
7
7
 
8
- import { execSync, execFileSync, spawn } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import * as os from 'os';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
- import type { ToolRegistry, ToolDefinition } from '../core/tool';
12
+ import type { ToolRegistry } from '../core/tool';
13
13
 
14
14
  const MAX_OUT = 8000;
15
15
 
package/src/web/server.ts CHANGED
@@ -8,11 +8,8 @@
8
8
 
9
9
  import { createServer, IncomingMessage, ServerResponse } from "http";
10
10
  import { createSystemContext } from "../core/factory";
11
- import { getLogger } from "../core/logger";
12
11
  import { renderInkWashUI, SKYLOOM_FAVICON_SVG } from "./ui";
13
12
 
14
- const log = getLogger("web-server");
15
-
16
13
  /* ──────────────────────────────────────────────
17
14
  Server
18
15
  ────────────────────────────────────────────── */
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { SystemContext, TaskExecutionResult } from "../src/core/factory";
3
+ import { MessageBus } from "../src/core/bus";
4
+
5
+ describe("factory · TaskExecutionResult", () => {
6
+ it("holds the provided fields", () => {
7
+ const r = new TaskExecutionResult({ id: "t1", agent: "fog", description: "do x", success: true, content: "done" });
8
+ expect(r).toMatchObject({ id: "t1", agent: "fog", success: true, content: "done" });
9
+ });
10
+ });
11
+
12
+ describe("factory · SystemContext", () => {
13
+ function ctx(overrides: Partial<ConstructorParameters<typeof SystemContext>[0]> = {}) {
14
+ return new SystemContext({
15
+ config: { agents: {} } as any,
16
+ bus: new MessageBus(),
17
+ llm: {} as any,
18
+ agentMap: new Map(),
19
+ toolRegistry: {} as any,
20
+ ...overrides,
21
+ });
22
+ }
23
+
24
+ it("stores constructor options with sensible defaults", () => {
25
+ const c = ctx({ workspacePath: "/ws" });
26
+ expect(c.workspacePath).toBe("/ws");
27
+ expect(c.mcp).toBeNull();
28
+ expect(c.mcpStatus).toEqual([]);
29
+ expect(c.agentMap.size).toBe(0);
30
+ });
31
+
32
+ it("initAll fires the plugin init hook before agents come up", async () => {
33
+ const emit = vi.fn().mockResolvedValue(undefined);
34
+ const c = ctx({ plugins: { emit } as any });
35
+ await c.initAll();
36
+ expect(emit).toHaveBeenCalledWith("init", expect.objectContaining({ config: expect.anything() }));
37
+ });
38
+
39
+ it("closeAll closes every agent and tolerates a missing mcp", async () => {
40
+ const close = vi.fn().mockResolvedValue(undefined);
41
+ const agents = new Map<string, any>([
42
+ ["fog", { close }],
43
+ ["rain", { close }],
44
+ ]);
45
+ const c = ctx({ agentMap: agents as any });
46
+ await c.closeAll();
47
+ expect(close).toHaveBeenCalledTimes(2);
48
+ });
49
+
50
+ it("closeAll also closes mcp when present", async () => {
51
+ const closeAll = vi.fn().mockResolvedValue(undefined);
52
+ const c = ctx({ mcp: { closeAll } as any });
53
+ await c.closeAll();
54
+ expect(closeAll).toHaveBeenCalled();
55
+ });
56
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * MCP SSE transport — integration test against a local mock SSE server.
3
+ *
4
+ * Exercises the full handshake: GET event-stream → `endpoint` event →
5
+ * initialize → tools/list → tools/call, with every response delivered back
6
+ * over the persistent SSE stream and correlated by JSON-RPC id.
7
+ */
8
+ import { describe, it, expect, afterEach } from "vitest";
9
+ import * as http from "http";
10
+ import { AddressInfo } from "net";
11
+ import { MCPClient } from "../src/core/mcp";
12
+
13
+ /**
14
+ * A minimal MCP-over-SSE server: one event-stream connection, a POST endpoint
15
+ * that echoes JSON-RPC responses back over that stream.
16
+ */
17
+ function startMockSSEServer(): Promise<{ url: string; close: () => void }> {
18
+ let sseRes: http.ServerResponse | null = null;
19
+
20
+ const server = http.createServer((req, res) => {
21
+ if (req.method === "GET") {
22
+ res.writeHead(200, {
23
+ "Content-Type": "text/event-stream",
24
+ "Cache-Control": "no-cache",
25
+ Connection: "keep-alive",
26
+ });
27
+ sseRes = res;
28
+ // Advertise the message endpoint (relative path, resolved by the client).
29
+ res.write("event: endpoint\ndata: /messages\n\n");
30
+ return;
31
+ }
32
+
33
+ if (req.method === "POST") {
34
+ let body = "";
35
+ req.on("data", (c) => (body += c));
36
+ req.on("end", () => {
37
+ res.writeHead(202).end();
38
+ const msg = JSON.parse(body);
39
+ if (msg.id === undefined || msg.id === null) return; // notification
40
+ const reply = (result: unknown) => {
41
+ sseRes?.write(
42
+ `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })}\n\n`
43
+ );
44
+ };
45
+ if (msg.method === "initialize") {
46
+ reply({ protocolVersion: "2025-03-26", capabilities: {} });
47
+ } else if (msg.method === "tools/list") {
48
+ reply({ tools: [{ name: "echo", description: "echo back" }] });
49
+ } else if (msg.method === "tools/call") {
50
+ reply({ content: [{ type: "text", text: `hi ${msg.params.arguments.who}` }] });
51
+ } else if (msg.method === "ping") {
52
+ reply({});
53
+ }
54
+ });
55
+ return;
56
+ }
57
+
58
+ res.writeHead(404).end();
59
+ });
60
+
61
+ return new Promise((resolve) => {
62
+ server.listen(0, "127.0.0.1", () => {
63
+ const port = (server.address() as AddressInfo).port;
64
+ resolve({
65
+ url: `http://127.0.0.1:${port}/sse`,
66
+ close: () => {
67
+ sseRes?.end();
68
+ server.close();
69
+ },
70
+ });
71
+ });
72
+ });
73
+ }
74
+
75
+ describe("MCP SSE transport", () => {
76
+ let mock: { url: string; close: () => void } | null = null;
77
+ let client: MCPClient | null = null;
78
+
79
+ afterEach(async () => {
80
+ await client?.close();
81
+ mock?.close();
82
+ client = null;
83
+ mock = null;
84
+ });
85
+
86
+ it("completes the endpoint handshake and lists tools", async () => {
87
+ mock = await startMockSSEServer();
88
+ client = new MCPClient({ name: "mock", url: mock.url });
89
+
90
+ const tools = await client.initialize();
91
+ expect(tools.map((t) => t.name)).toEqual(["echo"]);
92
+ expect(client.getToolDefinitions()).toHaveLength(1);
93
+ });
94
+
95
+ it("calls a tool and extracts the text result over the stream", async () => {
96
+ mock = await startMockSSEServer();
97
+ client = new MCPClient({ name: "mock", url: mock.url });
98
+
99
+ await client.initialize();
100
+ const result = await client.callTool("echo", { who: "skyloom" });
101
+ expect(result).toBe("hi skyloom");
102
+ });
103
+
104
+ it("reports healthy after the handshake via an SSE ping round-trip", async () => {
105
+ mock = await startMockSSEServer();
106
+ client = new MCPClient({ name: "mock", url: mock.url });
107
+
108
+ await client.initialize();
109
+ const health = await client.healthCheck();
110
+ expect(health.healthy).toBe(true);
111
+ });
112
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ matchPipeline,
4
+ matchAllPipelines,
5
+ buildTasksFromPipeline,
6
+ listPipelines,
7
+ getPipelineByName,
8
+ validateDAG,
9
+ topologicalSort,
10
+ type Task,
11
+ } from "../src/core/pipelines";
12
+
13
+ function task(id: string, dependsOn: string[] = []): Task {
14
+ return { id, description: id, assignedTo: "fog", parentId: dependsOn[0] ?? null, dependsOn };
15
+ }
16
+
17
+ describe("pipelines · matching", () => {
18
+ it("matches a code-review goal to the code_review pipeline", () => {
19
+ const p = matchPipeline("帮我审查代码");
20
+ expect(p?.name).toBe("code_review");
21
+ });
22
+
23
+ it("matches an English review goal", () => {
24
+ expect(matchPipeline("please review my code")?.name).toBe("code_review");
25
+ });
26
+
27
+ it("returns null for an unmatched / empty goal", () => {
28
+ expect(matchPipeline("今天天气怎么样")).toBeNull();
29
+ expect(matchPipeline("")).toBeNull();
30
+ });
31
+
32
+ it("matchAllPipelines returns an array (possibly empty)", () => {
33
+ expect(Array.isArray(matchAllPipelines("审查代码"))).toBe(true);
34
+ expect(matchAllPipelines("xyzzy nonsense")).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe("pipelines · introspection", () => {
39
+ it("lists pipelines with names, triggers and steps", () => {
40
+ const list = listPipelines();
41
+ expect(list.length).toBeGreaterThan(0);
42
+ for (const p of list) {
43
+ expect(typeof p.name).toBe("string");
44
+ expect(Array.isArray(p.triggers)).toBe(true);
45
+ expect(Array.isArray(p.steps)).toBe(true);
46
+ }
47
+ });
48
+
49
+ it("getPipelineByName round-trips a listed name; unknown → null", () => {
50
+ const name = listPipelines()[0].name as string;
51
+ expect(getPipelineByName(name)?.name).toBe(name);
52
+ expect(getPipelineByName("___nope___")).toBeNull();
53
+ });
54
+ });
55
+
56
+ describe("pipelines · materialization", () => {
57
+ it("builds tasks from a pipeline, substituting {goal}", () => {
58
+ const p = getPipelineByName("code_review")!;
59
+ const tasks = buildTasksFromPipeline(p, "登录模块");
60
+ expect(tasks.length).toBe(p.steps.length);
61
+ expect(tasks[0].description).toContain("登录模块");
62
+ expect(tasks[0].metadata?.goal).toBe("登录模块");
63
+ expect(tasks[0].metadata?.pipeline).toBe("code_review");
64
+ });
65
+
66
+ it("a materialized pipeline is a valid DAG", () => {
67
+ for (const meta of listPipelines()) {
68
+ const p = getPipelineByName(meta.name as string)!;
69
+ const tasks = buildTasksFromPipeline(p, "x");
70
+ const v = validateDAG(tasks);
71
+ expect(v.valid, `${meta.name}: ${v.errors.join("; ")}`).toBe(true);
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("pipelines · validateDAG", () => {
77
+ it("accepts a linear chain", () => {
78
+ const v = validateDAG([task("1"), task("2", ["1"]), task("3", ["2"])]);
79
+ expect(v.valid).toBe(true);
80
+ expect(v.errors).toEqual([]);
81
+ });
82
+
83
+ it("flags a missing dependency", () => {
84
+ const v = validateDAG([task("2", ["1"])]); // 1 doesn't exist
85
+ expect(v.valid).toBe(false);
86
+ expect(v.errors.join(" ")).toMatch(/non-existent/);
87
+ });
88
+
89
+ it("detects a cycle", () => {
90
+ const v = validateDAG([task("a", ["b"]), task("b", ["a"])]);
91
+ expect(v.valid).toBe(false);
92
+ expect(v.errors.join(" ")).toMatch(/[Cc]ycle/);
93
+ });
94
+ });
95
+
96
+ describe("pipelines · topologicalSort", () => {
97
+ it("orders dependencies before dependents", () => {
98
+ const sorted = topologicalSort([task("3", ["2"]), task("1"), task("2", ["1"])]);
99
+ const order = sorted.map((t) => t.id);
100
+ expect(order.indexOf("1")).toBeLessThan(order.indexOf("2"));
101
+ expect(order.indexOf("2")).toBeLessThan(order.indexOf("3"));
102
+ });
103
+
104
+ it("handles independent tasks (all in-degree 0)", () => {
105
+ const sorted = topologicalSort([task("a"), task("b"), task("c")]);
106
+ expect(sorted.map((t) => t.id).sort()).toEqual(["a", "b", "c"]);
107
+ });
108
+
109
+ it("a diamond DAG keeps the root first and the join last", () => {
110
+ // a → b, a → c, b → d, c → d
111
+ const sorted = topologicalSort([
112
+ task("d", ["b", "c"]), task("b", ["a"]), task("c", ["a"]), task("a"),
113
+ ]);
114
+ const order = sorted.map((t) => t.id);
115
+ expect(order[0]).toBe("a");
116
+ expect(order[order.length - 1]).toBe("d");
117
+ });
118
+ });
@@ -4,6 +4,7 @@
4
4
  import { describe, it, expect } from 'vitest';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
+ import * as os from 'os';
7
8
 
8
9
  describe('Skill', () => {
9
10
  it('creates a skill with model override', async () => {
@@ -42,7 +43,7 @@ describe('Skill', () => {
42
43
  describe('Skill.fromMarkdown', () => {
43
44
  it('loads skill with YAML frontmatter', async () => {
44
45
  const { Skill } = await import('../src/core/skill');
45
- const tmpDir = fs.mkdtempSync('skill-test-');
46
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-test-'));
46
47
  const mdPath = path.join(tmpDir, 'test.md');
47
48
  fs.writeFileSync(mdPath, `---
48
49
  name: test_skill
@@ -69,7 +70,7 @@ This is a test skill.
69
70
 
70
71
  it('supports string path', async () => {
71
72
  const { Skill } = await import('../src/core/skill');
72
- const tmpDir = fs.mkdtempSync('skill-test-');
73
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-test-'));
73
74
  const mdPath = path.join(tmpDir, 'x.md');
74
75
  fs.writeFileSync(mdPath, '---\nname: x\ndescription: x\n---\n\nBody.', 'utf-8');
75
76
  const s = Skill.fromMarkdown(mdPath);
@@ -83,7 +84,7 @@ This is a test skill.
83
84
 
84
85
  it('derives triggers from quoted descriptions', async () => {
85
86
  const { Skill } = await import('../src/core/skill');
86
- const tmpDir = fs.mkdtempSync('skill-test-');
87
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-test-'));
87
88
  const mdPath = path.join(tmpDir, 'pptx.md');
88
89
  fs.writeFileSync(mdPath, `---
89
90
  name: pptx
@@ -108,7 +109,7 @@ Body.`, 'utf-8');
108
109
 
109
110
  it('small body loaded in full', async () => {
110
111
  const { Skill } = await import('../src/core/skill');
111
- const tmpDir = fs.mkdtempSync('skill-test-');
112
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-test-'));
112
113
  const mdPath = path.join(tmpDir, 'small.md');
113
114
  fs.writeFileSync(mdPath, '---\nname: small\ndescription: x\n---\n\n# Small Skill\n\nThis fits inline easily.', 'utf-8');
114
115
  const s = Skill.fromMarkdown(mdPath);
@@ -122,7 +123,7 @@ Body.`, 'utf-8');
122
123
 
123
124
  it('large body truncated to head', async () => {
124
125
  const { Skill } = await import('../src/core/skill');
125
- const tmpDir = fs.mkdtempSync('skill-test-');
126
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-test-'));
126
127
  const mdPath = path.join(tmpDir, 'big.md');
127
128
 
128
129
  let body = '# Big Skill\n\n## Quick Reference\nFirst section content.\n\n';
@@ -1,10 +0,0 @@
1
- ---
2
- name: test_skill
3
- description: A test
4
- model: claude-sonnet-4-6
5
- temperature: 0.5
6
- max_tokens: 8192
7
- ---
8
-
9
- ## Test Skill
10
- This is a test skill.