Compare commits

...

2 Commits

Author SHA1 Message Date
李岩岩
4ca19bf301 feat: 拆分 Models Extension 2026-08-04 17:44:47 +08:00
李岩岩
1665763efc feat: 按 Extension ID 装载初始化配置 2026-08-04 17:41:28 +08:00
20 changed files with 604 additions and 95 deletions

View File

@ -17,6 +17,8 @@ pnpm dev
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
Runtime 会读取 `<LLM_TO_AGENT_HOME>/config/extensions.json` 和当前 Workspace 下同结构的 `config/extensions.json`;文件缺失时继续使用默认值,不会自动创建。
文档:
- [架构总览](docs/architecture.md)

View File

@ -39,6 +39,18 @@ Extension 是 Runtime 的独立装配单位,分为三类:
最终能力归属见 [Extension 规划](extensions.md)。
## 配置
Runtime 启动时读取全局和当前 Workspace 的可选配置Workspace 配置按字段覆盖全局配置。产品装配向 Kernel 提供 Extension 工厂函数,每个工厂函数用自身的静态 `id` 表明身份:
```ts
createDeepSeekExtension.id = ExtensionId.DeepSeek;
```
`kernel.use(createDeepSeekExtension)` 根据这个 `id` 取得对应配置,调用 `createDeepSeekExtension(options)`,再保存创建出的 Extension 实例。配置文件位置、作用域和合并规则都不进入 Kernel。
Extension 负责解释和校验自己的片段。第一版配置只在启动时读取,不创建缺失文件、不写入默认值,也不支持热更新。
## 扩展点
多个实现向能力所有者登记,不再为扩展点增加新的架构层:

View File

@ -15,9 +15,12 @@
```text
<LLM_TO_AGENT_HOME>/
├── config/
│ └── extensions.json
├── workspaces/
│ └── <workspace-id>/
│ ├── workspace.json
│ ├── config/
│ │ └── extensions.json
│ ├── files/
│ ├── conversations/
│ ├── artifacts/
@ -32,6 +35,26 @@
目录按需要创建,不预建尚未使用的层级。
## Extension 配置
全局和 Workspace 配置使用相同结构:
```json
{
"version": 1,
"extensions": {
"models": {
"defaultProvider": "deepseek"
},
"deepseek": {
"model": "deepseek-v4-flash"
}
}
}
```
Runtime 先读取全局配置,再使用 Workspace 中同 Extension、同字段的值覆盖它。两个文件都是可选的第一版只读不自动创建、补全或修改配置文件。
## 数据归属
- `workspace` 管理 Workspace、Conversation、Message 和 Artifact

View File

@ -13,6 +13,7 @@ llm-to-agent/
│ │ ├── web/
│ │ └── desktop/
│ ├── products/
│ ├── config.ts
│ └── main.ts
├── docs/
├── tests/
@ -28,6 +29,7 @@ llm-to-agent/
- `extensions/shared/`:跨产品使用的能力和 Provider
- `extensions/cli|web|desktop/`:产品输入、展示和平台集成;
- `products/`:静态选择产品启用的 Extension不写业务逻辑
- `config.ts`:读取并合并全局与 Workspace Extension 配置;
- `tests/`:跨 Extension、跨进程或跨版本测试
- `tooling/`:构建、开发和发布辅助。

79
src/config.test.ts Normal file
View File

@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { loadRuntimeConfig, workspaceIdFor } from "./config";
test("merges global and workspace extension configs", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
const workspaceId = workspaceIdFor(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
await mkdir(join(home, "config"), { recursive: true });
await mkdir(join(home, "workspaces", workspaceId, "config"), {
recursive: true,
});
await writeFile(
join(home, "config", "extensions.json"),
JSON.stringify({
version: 1,
extensions: {
models: { defaultProvider: "deepseek" },
shell: { timeoutMs: 30_000, maxOutputLength: 50_000 },
},
}),
);
await writeFile(
join(home, "workspaces", workspaceId, "config", "extensions.json"),
JSON.stringify({
version: 1,
extensions: {
shell: { timeoutMs: 120_000 },
project: { checks: ["pnpm test"] },
},
}),
);
const config = await loadRuntimeConfig({ home, projectPath });
assert.equal(config.workspaceId, workspaceId);
assert.deepEqual(config.extensions, {
models: { defaultProvider: "deepseek" },
shell: { timeoutMs: 120_000, maxOutputLength: 50_000 },
project: { checks: ["pnpm test"] },
});
});
test("does not create missing config files", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const config = await loadRuntimeConfig({ home, projectPath });
assert.deepEqual(config.extensions, {});
assert.deepEqual(await readdir(temporaryDirectory), []);
});
test("rejects invalid extension config sections", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
await mkdir(join(home, "config"), { recursive: true });
await writeFile(
join(home, "config", "extensions.json"),
JSON.stringify({ version: 1, extensions: { models: "deepseek" } }),
);
await assert.rejects(
loadRuntimeConfig({ home, projectPath }),
/invalid "models" section/,
);
});

104
src/config.ts Normal file
View File

@ -0,0 +1,104 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { ExtensionConfig } from "./kernel";
export type ExtensionConfigs = Record<string, ExtensionConfig>;
export interface RuntimeConfigOptions {
home?: string;
projectPath?: string;
}
export interface RuntimeConfig {
home: string;
projectPath: string;
workspaceId: string;
extensions: ExtensionConfigs;
}
export function workspaceIdFor(projectPath: string): string {
return createHash("sha256").update(resolve(projectPath)).digest("hex").slice(0, 16);
}
export async function loadRuntimeConfig(
options: RuntimeConfigOptions = {},
): Promise<RuntimeConfig> {
const home = resolve(
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
);
const projectPath = resolve(options.projectPath ?? process.cwd());
const workspaceId = workspaceIdFor(projectPath);
const globalFile = join(home, "config", "extensions.json");
const workspaceFile = join(
home,
"workspaces",
workspaceId,
"config",
"extensions.json",
);
async function readExtensions(file: string): Promise<ExtensionConfigs> {
let content: string;
try {
content = await readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`Invalid JSON in extension config "${file}".`, {
cause: error,
});
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Extension config "${file}" must be an object.`);
}
const document = parsed as Record<string, unknown>;
if (document.version !== 1) {
throw new Error(`Extension config "${file}" must use version 1.`);
}
if (
!document.extensions ||
typeof document.extensions !== "object" ||
Array.isArray(document.extensions)
) {
throw new Error(`Extension config "${file}" must contain extensions.`);
}
const extensions: ExtensionConfigs = {};
for (const [id, config] of Object.entries(document.extensions)) {
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error(
`Extension config "${file}" contains an invalid "${id}" section.`,
);
}
extensions[id] = config as ExtensionConfig;
}
return extensions;
}
const globalExtensions = await readExtensions(globalFile);
const workspaceExtensions = await readExtensions(workspaceFile);
const extensions: ExtensionConfigs = {};
for (const [id, config] of Object.entries(globalExtensions)) {
extensions[id] = { ...config };
}
for (const [id, config] of Object.entries(workspaceExtensions)) {
extensions[id] = { ...extensions[id], ...config };
}
return { home, projectPath, workspaceId, extensions };
}

View File

@ -1,5 +1,6 @@
export const ExtensionId = {
Workspace: "workspace",
Models: "models",
DeepSeek: "deepseek",
Agent: "agent",
Cli: "cli",
@ -7,8 +8,9 @@ export const ExtensionId = {
export const Hook = {
Workspace: "workspace",
Models: "models",
Agent: "agent",
ModelProviders: "model.providers",
ModelProviders: "models.providers",
};
export const Event = {

View File

@ -5,12 +5,13 @@ import { join } from "node:path";
import test from "node:test";
import { Kernel } from "../kernel";
import { Hook } from "./catalog";
import { ExtensionId, Hook } from "./catalog";
import {
createAgentExtension,
type AgentService,
} from "./shared/agent";
import { createDeepSeekExtension } from "./shared/deepseek";
import { createModelsExtension } from "./shared/models";
import {
createWorkspaceExtension,
type WorkspaceService,
@ -51,10 +52,15 @@ test("streams a reply, saves it, and restores the conversation after restart", a
});
};
const firstKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
createDeepSeekExtension({ apiKey: "test-key", request }),
createAgentExtension(),
const extensionConfigs = {
[ExtensionId.Workspace]: { home, projectPath },
[ExtensionId.DeepSeek]: { apiKey: "test-key", request },
};
const firstKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
);
await firstKernel.start();
@ -67,10 +73,11 @@ test("streams a reply, saves it, and restores the conversation after restart", a
assert.equal(firstAnswer, "第一次回答");
await firstKernel.stop();
const secondKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
createDeepSeekExtension({ apiKey: "test-key", request }),
createAgentExtension(),
const secondKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
);
await secondKernel.start();
@ -109,8 +116,11 @@ test("creates a conversation and restores it after restart", async (t) => {
await mkdir(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const firstKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
const extensionConfigs = {
[ExtensionId.Workspace]: { home, projectPath },
};
const firstKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
);
await firstKernel.start();
@ -129,8 +139,8 @@ test("creates a conversation and restores it after restart", async (t) => {
assert.equal(await firstWorkspace.switchConversation(conversationId), true);
await firstKernel.stop();
const secondKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
const secondKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
);
await secondKernel.start();

View File

@ -15,8 +15,6 @@ export function createCliExtension(): Extension {
let currentRequest: AbortController | undefined;
return {
id: ExtensionId.Cli,
setup() {},
start(context) {
@ -125,3 +123,5 @@ export function createCliExtension(): Extension {
},
};
}
createCliExtension.id = ExtensionId.Cli;

View File

@ -5,15 +5,8 @@ import type {
ExtensionRuntimeContext,
} from "../../../kernel";
import { Event, ExtensionId, Hook } from "../../catalog";
import {
type ChatMessage,
type WorkspaceService,
} from "../workspace";
export interface ModelProvider {
id: string;
chat(messages: ChatMessage[], signal?: AbortSignal): AsyncIterable<string>;
}
import type { ModelService } from "../models";
import type { WorkspaceService } from "../workspace";
export interface AgentService {
chat(input: string, signal?: AbortSignal): AsyncIterable<string>;
@ -22,17 +15,17 @@ export interface AgentService {
export function createAgentExtension(): Extension {
let runtime: ExtensionRuntimeContext | undefined;
let workspace: WorkspaceService | undefined;
let model: ModelProvider | undefined;
let models: ModelService | undefined;
const agent: AgentService = {
async *chat(input, signal) {
if (!runtime || !workspace || !model) {
if (!runtime || !workspace || !models) {
throw new Error("Agent is not running.");
}
const activeRuntime = runtime;
const activeWorkspace = workspace;
const activeModel = model;
const activeModels = models;
const runId = randomUUID();
const userMessage = await activeWorkspace.append("user", input);
@ -46,9 +39,11 @@ export function createAgentExtension(): Extension {
let answer = "";
try {
const messages = await activeWorkspace.messages();
const messages = (await activeWorkspace.messages()).map(
({ role, content }) => ({ role, content }),
);
for await (const chunk of activeModel.chat(messages, signal)) {
for await (const chunk of activeModels.chat(messages, signal)) {
answer += chunk;
yield chunk;
}
@ -68,28 +63,22 @@ export function createAgentExtension(): Extension {
};
return {
id: ExtensionId.Agent,
setup(context) {
context.add(Hook.Agent, agent);
},
start(context) {
const providers = context.all<ModelProvider>(Hook.ModelProviders);
if (providers.length === 0) {
throw new Error("Agent needs at least one model provider.");
}
runtime = context;
workspace = context.get<WorkspaceService>(Hook.Workspace);
model = providers[0];
models = context.get<ModelService>(Hook.Models);
},
stop() {
runtime = undefined;
workspace = undefined;
model = undefined;
models = undefined;
},
};
}
createAgentExtension.id = ExtensionId.Agent;

View File

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Kernel } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import { createModelsExtension, type ModelService } from "../models";
import { createDeepSeekExtension } from ".";
test("reads DeepSeek settings from extension config", async (t) => {
const environment = {
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL,
};
delete process.env.DEEPSEEK_API_KEY;
delete process.env.DEEPSEEK_BASE_URL;
delete process.env.DEEPSEEK_MODEL;
t.after(() => {
for (const [name, value] of Object.entries(environment)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let requestUrl = "";
let requestInit: RequestInit | undefined;
const request = async (url: string, init: RequestInit) => {
requestUrl = url;
requestInit = init;
return new Response("data: [DONE]\n\n");
};
const kernel = new Kernel({
extensionConfigs: {
[ExtensionId.DeepSeek]: {
apiKey: "config-key",
baseUrl: "https://example.test/v1/",
model: "config-model",
request,
},
},
}).use(createModelsExtension, createDeepSeekExtension);
await kernel.start();
for await (const _ of kernel
.get<ModelService>(Hook.Models)
.chat([{ role: "user", content: "你好" }])) {
// The test response contains no text chunks.
}
assert.equal(requestUrl, "https://example.test/v1/chat/completions");
assert.equal(
(requestInit?.headers as Record<string, string>).authorization,
"Bearer config-key",
);
assert.equal(JSON.parse(String(requestInit?.body)).model, "config-model");
await kernel.stop();
});

View File

@ -1,6 +1,6 @@
import type { Extension } from "../../../kernel";
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import type { ModelProvider } from "../agent";
import type { ModelProvider } from "../models";
export interface DeepSeekOptions {
apiKey?: string;
@ -9,15 +9,30 @@ export interface DeepSeekOptions {
request?: (url: string, init: RequestInit) => Promise<Response>;
}
export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extension {
const apiKey = options.apiKey ?? process.env.DEEPSEEK_API_KEY;
export function createDeepSeekExtension(options: ExtensionConfig = {}): Extension {
if (options.apiKey !== undefined && typeof options.apiKey !== "string") {
throw new Error("deepseek.apiKey must be a string.");
}
if (options.baseUrl !== undefined && typeof options.baseUrl !== "string") {
throw new Error("deepseek.baseUrl must be a string.");
}
if (options.model !== undefined && typeof options.model !== "string") {
throw new Error("deepseek.model must be a string.");
}
if (options.request !== undefined && typeof options.request !== "function") {
throw new Error("deepseek.request must be a function.");
}
const config = options as DeepSeekOptions;
const apiKey = process.env.DEEPSEEK_API_KEY ?? config.apiKey;
const baseUrl = (
options.baseUrl ??
process.env.DEEPSEEK_BASE_URL ??
config.baseUrl ??
"https://api.deepseek.com"
).replace(/\/+$/, "");
const model = options.model ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
const request = options.request ?? fetch;
const model =
process.env.DEEPSEEK_MODEL ?? config.model ?? "deepseek-v4-flash";
const request = config.request ?? fetch;
const provider: ModelProvider = {
id: "deepseek",
@ -83,8 +98,6 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio
};
return {
id: ExtensionId.DeepSeek,
setup(context) {
if (!apiKey) {
throw new Error("DEEPSEEK_API_KEY is required.");
@ -94,3 +107,5 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio
},
};
}
createDeepSeekExtension.id = ExtensionId.DeepSeek;

View File

@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Kernel, type ExtensionSetupContext } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import {
createModelsExtension,
type ModelProvider,
type ModelService,
} from ".";
test("selects a provider and streams its response", async () => {
const requests: Array<{ role: string; content: string }[]> = [];
const ignoredProvider: ModelProvider = {
id: "ignored",
async *chat() {
yield "错误";
},
};
const provider: ModelProvider = {
id: "test",
async *chat(messages) {
requests.push(messages);
yield "你";
yield "好";
},
};
function createProviderExtension() {
return {
setup(context: ExtensionSetupContext) {
context.add(Hook.ModelProviders, ignoredProvider);
context.add(Hook.ModelProviders, provider);
},
};
}
createProviderExtension.id = "test-model-provider";
const kernel = new Kernel({
extensionConfigs: {
[ExtensionId.Models]: { defaultProvider: "test" },
},
}).use(createModelsExtension, createProviderExtension);
await kernel.start();
let answer = "";
for await (const chunk of kernel
.get<ModelService>(Hook.Models)
.chat([{ role: "user", content: "你好" }])) {
answer += chunk;
}
assert.equal(answer, "你好");
assert.deepEqual(requests, [[{ role: "user", content: "你好" }]]);
await kernel.stop();
});
test("requires at least one model provider", async () => {
const kernel = new Kernel().use(createModelsExtension);
await assert.rejects(kernel.start(), /at least one provider/);
});

View File

@ -0,0 +1,63 @@
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
export interface ModelMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface ModelProvider {
id: string;
chat(messages: ModelMessage[], signal?: AbortSignal): AsyncIterable<string>;
}
export interface ModelService {
chat(messages: ModelMessage[], signal?: AbortSignal): AsyncIterable<string>;
}
export function createModelsExtension(options: ExtensionConfig = {}): Extension {
if (
options.defaultProvider !== undefined &&
typeof options.defaultProvider !== "string"
) {
throw new Error("models.defaultProvider must be a string.");
}
const defaultProvider = options.defaultProvider;
let provider: ModelProvider | undefined;
const models: ModelService = {
chat(messages, signal) {
if (!provider) throw new Error("Models is not running.");
return provider.chat(messages, signal);
},
};
return {
setup(context) {
context.add(Hook.Models, models);
},
start(context) {
const providers = context.all<ModelProvider>(Hook.ModelProviders);
if (providers.length === 0) {
throw new Error("Models needs at least one provider.");
}
provider = defaultProvider
? providers.find(({ id }) => id === defaultProvider)
: providers[0];
if (!provider) {
throw new Error(`Model provider "${defaultProvider}" is not registered.`);
}
},
stop() {
provider = undefined;
},
};
}
createModelsExtension.id = ExtensionId.Models;

View File

@ -1,9 +1,10 @@
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { Extension } from "../../../kernel";
import { workspaceIdFor } from "../../../config";
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
export type MessageRole = "system" | "user" | "assistant";
@ -31,16 +32,33 @@ export interface WorkspaceOptions {
conversationId?: string;
}
export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extension {
export function createWorkspaceExtension(options: ExtensionConfig = {}): Extension {
if (options.home !== undefined && typeof options.home !== "string") {
throw new Error("workspace.home must be a string.");
}
if (
options.projectPath !== undefined &&
typeof options.projectPath !== "string"
) {
throw new Error("workspace.projectPath must be a string.");
}
if (
options.conversationId !== undefined &&
typeof options.conversationId !== "string"
) {
throw new Error("workspace.conversationId must be a string.");
}
const config = options as WorkspaceOptions;
const home = resolve(
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
config.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
);
const projectPath = resolve(options.projectPath ?? process.cwd());
const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16);
const projectPath = resolve(config.projectPath ?? process.cwd());
const workspaceId = workspaceIdFor(projectPath);
const workspaceDirectory = join(home, "workspaces", workspaceId);
const conversationsDirectory = join(workspaceDirectory, "conversations");
const workspaceFile = join(workspaceDirectory, "workspace.json");
let conversationId = options.conversationId ?? "default";
let conversationId = config.conversationId ?? "default";
async function saveWorkspace() {
await writeFile(
@ -123,12 +141,10 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
};
return {
id: ExtensionId.Workspace,
async setup(context) {
await mkdir(workspaceDirectory, { recursive: true });
if (!options.conversationId) {
if (!config.conversationId) {
try {
const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8"));
if (savedWorkspace.activeConversationId) {
@ -146,3 +162,5 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
},
};
}
createWorkspaceExtension.id = ExtensionId.Workspace;

View File

@ -1,6 +1,7 @@
import type { EventHandler, Unsubscribe } from "./events";
export type Awaitable<T> = T | Promise<T>;
export type ExtensionConfig = Record<string, unknown>;
export interface ExtensionSetupContext {
add<T>(name: string, value: T): void;
@ -15,10 +16,12 @@ export interface ExtensionRuntimeContext {
}
export interface Extension {
id: string;
setup(context: ExtensionSetupContext): Awaitable<void>;
start?(context: ExtensionRuntimeContext): Awaitable<void>;
stop?(context: ExtensionRuntimeContext): Awaitable<void>;
}
export type ExtensionFactory = () => Extension;
export interface ExtensionFactory {
id: string;
(options?: ExtensionConfig): Extension;
}

View File

@ -1,13 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { EventBus, Kernel, type Extension } from "./index";
import {
EventBus,
Kernel,
type Extension,
type ExtensionFactory,
} from "./index";
function extensionFactory(
id: string,
createExtension: () => Extension,
): ExtensionFactory {
function create() {
return createExtension();
}
create.id = id;
return create;
}
test("sets up every extension before starting them and stops in reverse order", async () => {
const calls: string[] = [];
const first: Extension = {
id: "first",
const first = extensionFactory("first", () => ({
setup(context) {
calls.push("first.setup");
context.add("test.values", "first");
@ -18,10 +33,9 @@ test("sets up every extension before starting them and stops in reverse order",
stop() {
calls.push("first.stop");
},
};
}));
const second: Extension = {
id: "second",
const second = extensionFactory("second", () => ({
setup(context) {
calls.push("second.setup");
context.add("test.values", "second");
@ -32,7 +46,7 @@ test("sets up every extension before starting them and stops in reverse order",
stop() {
calls.push("second.stop");
},
};
}));
const kernel = new Kernel().use(first, second);
await kernel.start();
@ -49,23 +63,53 @@ test("sets up every extension before starting them and stops in reverse order",
});
test("rejects duplicate extension ids without partially installing a batch", () => {
const first: Extension = { id: "first", setup() {} };
const duplicate: Extension = { id: "first", setup() {} };
const first = extensionFactory("first", () => ({ setup() {} }));
const duplicate = extensionFactory("first", () => ({ setup() {} }));
const kernel = new Kernel();
assert.throws(() => kernel.use(first, duplicate), /already installed/);
assert.deepEqual(kernel.installedExtensionIds, []);
});
test("passes extension config by id when constructing", async () => {
const extensionConfigs = {
first: { value: "configured" },
second: { enabled: true },
};
const received: Record<string, unknown>[] = [];
function createFirst(options: Record<string, unknown> = {}) {
received.push(options);
return { setup() {} };
}
createFirst.id = "first";
function createUnconfigured(options: Record<string, unknown> = {}) {
received.push(options);
return { setup() {} };
}
createUnconfigured.id = "unconfigured";
const kernel = new Kernel({ extensionConfigs }).use(
createFirst,
createUnconfigured,
);
extensionConfigs.first.value = "changed after use";
await kernel.start();
assert.deepEqual(received, [{ value: "configured" }, {}]);
await kernel.stop();
});
test("gets one value or collects multiple values", async () => {
const kernel = new Kernel().use({
id: "values",
const kernel = new Kernel().use(extensionFactory("values", () => ({
setup(context) {
context.add("single", "one");
context.add("multiple", "one");
context.add("multiple", "two");
},
});
})));
await kernel.start();
@ -76,13 +120,12 @@ test("gets one value or collects multiple values", async () => {
});
test("clears registrations when setup fails", async () => {
const kernel = new Kernel().use({
id: "broken-setup",
const kernel = new Kernel().use(extensionFactory("broken-setup", () => ({
setup(context) {
context.add("temporary", "value");
throw new Error("setup failed");
},
});
})));
await assert.rejects(kernel.start(), /setup failed/);
@ -93,21 +136,19 @@ test("clears registrations when setup fails", async () => {
test("stops every active extension after cleanup errors", async () => {
const calls: string[] = [];
const kernel = new Kernel().use(
{
id: "first",
extensionFactory("first", () => ({
setup() {},
stop() {
calls.push("first.stop");
},
},
{
id: "second",
})),
extensionFactory("second", () => ({
setup() {},
stop() {
calls.push("second.stop");
throw new Error("cleanup failed");
},
},
})),
);
await kernel.start();

View File

@ -1,23 +1,36 @@
import { EventBus, type EventHandler, type Unsubscribe } from "./events";
import type {
Extension,
ExtensionConfig,
ExtensionFactory,
ExtensionRuntimeContext,
ExtensionSetupContext,
} from "./extension";
import { ExtensionRegistry } from "./registry";
export interface KernelOptions {
extensionConfigs?: Record<string, ExtensionConfig>;
}
interface InstalledExtension {
id: string;
extension: Extension;
}
export class Kernel {
#extensions: Extension[] = [];
#extensions: InstalledExtension[] = [];
#activeExtensions: Extension[] = [];
#registry = new ExtensionRegistry();
#events = new EventBus();
#setupContext: ExtensionSetupContext;
#runtimeContext: ExtensionRuntimeContext;
#extensionConfigs: Record<string, ExtensionConfig>;
#state = "created";
constructor() {
constructor(options: KernelOptions = {}) {
const registry = this.#registry;
const events = this.#events;
this.#extensionConfigs = options.extensionConfigs ?? {};
this.#setupContext = {
add(name, value) {
@ -49,23 +62,27 @@ export class Kernel {
}
get installedExtensionIds(): string[] {
return this.#extensions.map((extension) => extension.id);
return this.#extensions.map(({ id }) => id);
}
use(...extensions: Extension[]): this {
use(...factories: ExtensionFactory[]): this {
if (this.#state !== "created") {
throw new Error(`Cannot install extensions while kernel is ${this.#state}.`);
}
const ids = new Set(this.#extensions.map((extension) => extension.id));
const ids = new Set(this.#extensions.map(({ id }) => id));
for (const extension of extensions) {
if (ids.has(extension.id)) {
throw new Error(`Extension "${extension.id}" is already installed.`);
for (const factory of factories) {
if (ids.has(factory.id)) {
throw new Error(`Extension "${factory.id}" is already installed.`);
}
ids.add(extension.id);
ids.add(factory.id);
}
const extensions = factories.map((factory) => ({
id: factory.id,
extension: factory({ ...this.#extensionConfigs[factory.id] }),
}));
this.#extensions.push(...extensions);
return this;
}
@ -94,11 +111,11 @@ export class Kernel {
this.#state = "starting";
try {
for (const extension of this.#extensions) {
for (const { extension } of this.#extensions) {
await extension.setup(this.#setupContext);
}
for (const extension of this.#extensions) {
for (const { extension } of this.#extensions) {
this.#activeExtensions.push(extension);
await extension.start?.(this.#runtimeContext);
}

View File

@ -1,7 +1,8 @@
import { existsSync } from "node:fs";
import { loadEnvFile } from "node:process";
import { Event } from "./extensions/catalog";
import { loadRuntimeConfig } from "./config";
import { Event, ExtensionId } from "./extensions/catalog";
import { Kernel } from "./kernel";
import { getProduct, sharedExtensions } from "./products";
@ -9,13 +10,22 @@ if (existsSync(".env")) {
loadEnvFile();
}
const runtimeConfig = await loadRuntimeConfig();
const productIds = process.argv.slice(2);
const products = (productIds.length > 0 ? productIds : ["cli"]).map(getProduct);
const factories = [
...sharedExtensions,
...products.flatMap((product) => product.extensions),
];
const kernel = new Kernel().use(...factories.map((factory) => factory()));
const extensionConfigs = {
...runtimeConfig.extensions,
[ExtensionId.Workspace]: {
...runtimeConfig.extensions[ExtensionId.Workspace],
home: runtimeConfig.home,
projectPath: runtimeConfig.projectPath,
},
};
const kernel = new Kernel({ extensionConfigs }).use(...factories);
const stopped = new Promise<void>((resolve) => {
kernel.on(Event.RuntimeStopRequested, resolve);

View File

@ -1,10 +1,12 @@
import type { ExtensionFactory } from "../kernel";
import { createAgentExtension } from "../extensions/shared/agent";
import { createDeepSeekExtension } from "../extensions/shared/deepseek";
import { createModelsExtension } from "../extensions/shared/models";
import { createWorkspaceExtension } from "../extensions/shared/workspace";
export const sharedExtensions: ExtensionFactory[] = [
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
];