summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/models
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
committerAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
commitadc8bd185b54935e7a31aae04da3175b7989927a (patch)
tree031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/core/src/models
parent52af4ca33b1f83b8f863a6267d447944f55e729d (diff)
downloaddispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz
dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar - Model/key resolution: tag-based model selection, key fallback chains - Skills system: directory loader with TOML frontmatter, agent mappings - Task list tool: add/update/list/get operations with WebSocket events - API routes: GET /config, /skills, /skills/:name, /models, /models/resolve - Frontend: sidebar with model status, task list, config viewer, skills browser, permission log - Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/core/src/models')
-rw-r--r--packages/core/src/models/index.ts2
-rw-r--r--packages/core/src/models/registry.ts134
-rw-r--r--packages/core/src/models/resolver.ts88
3 files changed, 224 insertions, 0 deletions
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
new file mode 100644
index 0000000..e5efcc8
--- /dev/null
+++ b/packages/core/src/models/index.ts
@@ -0,0 +1,2 @@
+export { ModelRegistry } from "./registry.js";
+export { ModelResolver } from "./resolver.js";
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
new file mode 100644
index 0000000..f78fe0e
--- /dev/null
+++ b/packages/core/src/models/registry.ts
@@ -0,0 +1,134 @@
+import type { KeyDefinition, KeyState, ModelDefinition } from "../types/index.js";
+
+export class ModelRegistry {
+ private models: ModelDefinition[];
+ private keyStates: Map<string, KeyState>;
+ private fallbackOrder: string[];
+
+ constructor(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]) {
+ this.models = [];
+ this.keyStates = new Map();
+ this.fallbackOrder = [];
+ this._initConfig(models, keys, fallbackOrder, new Map());
+ }
+
+ private _initConfig(
+ models: ModelDefinition[],
+ keys: KeyDefinition[],
+ fallbackOrder: string[],
+ existingStates: Map<string, KeyState>,
+ ): void {
+ this.models = [...models];
+ this.fallbackOrder = this._buildFallbackOrder(keys, fallbackOrder);
+
+ const newStates = new Map<string, KeyState>();
+ for (const key of keys) {
+ const existing = existingStates.get(key.id);
+ if (existing) {
+ // Preserve existing state but update definition
+ newStates.set(key.id, { ...existing, definition: key });
+ } else {
+ newStates.set(key.id, { definition: key, status: "active" });
+ }
+ }
+ this.keyStates = newStates;
+ }
+
+ private _buildFallbackOrder(keys: KeyDefinition[], fallbackOrder: string[]): string[] {
+ const ordered: string[] = [];
+ const keyIds = new Set(keys.map((k) => k.id));
+
+ // Add keys from fallbackOrder first (if they exist)
+ for (const id of fallbackOrder) {
+ if (keyIds.has(id) && !ordered.includes(id)) {
+ ordered.push(id);
+ }
+ }
+
+ // Append remaining keys not in fallbackOrder
+ for (const key of keys) {
+ if (!ordered.includes(key.id)) {
+ ordered.push(key.id);
+ }
+ }
+
+ return ordered;
+ }
+
+ getModels(): ModelDefinition[] {
+ return [...this.models];
+ }
+
+ getKeys(): KeyState[] {
+ return this.fallbackOrder
+ .map((id) => this.keyStates.get(id))
+ .filter((state): state is KeyState => state !== undefined);
+ }
+
+ getModelsByTag(tag: string): ModelDefinition[] {
+ return this.models.filter((m) => m.tags.includes(tag));
+ }
+
+ getAllTags(): string[] {
+ const tags = new Set<string>();
+ for (const model of this.models) {
+ for (const tag of model.tags) {
+ tags.add(tag);
+ }
+ }
+ return [...tags];
+ }
+
+ markKeyExhausted(keyId: string, error?: string): void {
+ const state = this.keyStates.get(keyId);
+ if (!state) return;
+ this.keyStates.set(keyId, {
+ ...state,
+ status: "exhausted",
+ lastError: error,
+ exhaustedAt: Date.now(),
+ });
+ }
+
+ markKeyActive(keyId: string): void {
+ const state = this.keyStates.get(keyId);
+ if (!state) return;
+ const updated: KeyState = {
+ definition: state.definition,
+ status: "active",
+ };
+ this.keyStates.set(keyId, updated);
+ }
+
+ hasAvailableKey(provider: string): boolean {
+ for (const state of this.keyStates.values()) {
+ if (state.definition.provider === provider && state.status === "active") {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ allKeysExhausted(): boolean {
+ for (const state of this.keyStates.values()) {
+ if (state.status === "active") {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ updateConfig(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]): void {
+ this._initConfig(models, keys, fallbackOrder, this.keyStates);
+ }
+
+ // Internal: get ordered key states for a specific provider
+ getOrderedKeysForProvider(provider: string): KeyState[] {
+ return this.fallbackOrder
+ .map((id) => this.keyStates.get(id))
+ .filter(
+ (state): state is KeyState =>
+ state !== undefined && state.definition.provider === provider,
+ );
+ }
+}
diff --git a/packages/core/src/models/resolver.ts b/packages/core/src/models/resolver.ts
new file mode 100644
index 0000000..3ada713
--- /dev/null
+++ b/packages/core/src/models/resolver.ts
@@ -0,0 +1,88 @@
+import type { ResolvedModel } from "../types/index.js";
+import type { ModelRegistry } from "./registry.js";
+
+export class ModelResolver {
+ private registry: ModelRegistry;
+
+ constructor(registry: ModelRegistry) {
+ this.registry = registry;
+ }
+
+ resolve(tag: string): ResolvedModel | null {
+ const models = this.registry.getModelsByTag(tag);
+ const keys = this.registry.getKeys();
+
+ for (const keyState of keys) {
+ if (keyState.status !== "active") continue;
+ const model = models.find((m) => m.provider === keyState.definition.provider);
+ if (model) {
+ return { model, key: keyState.definition };
+ }
+ }
+
+ return null;
+ }
+
+ async waitForKey(
+ tag: string,
+ options?: {
+ pollIntervalMs?: number;
+ signal?: AbortSignal;
+ onWaiting?: () => void;
+ onResume?: () => void;
+ },
+ ): Promise<ResolvedModel | null> {
+ const pollIntervalMs = options?.pollIntervalMs ?? 60000;
+ const signal = options?.signal;
+
+ // Try immediately first
+ const immediate = this.resolve(tag);
+ if (immediate) return immediate;
+
+ // Check if aborted before entering wait state
+ if (signal?.aborted) return null;
+
+ options?.onWaiting?.();
+
+ return new Promise<ResolvedModel | null>((resolve) => {
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ const cleanup = () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ const onAbort = () => {
+ cleanup();
+ resolve(null);
+ };
+
+ if (signal) {
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ const poll = () => {
+ if (signal?.aborted) {
+ resolve(null);
+ return;
+ }
+
+ const result = this.resolve(tag);
+ if (result) {
+ if (signal) {
+ signal.removeEventListener("abort", onAbort);
+ }
+ options?.onResume?.();
+ resolve(result);
+ return;
+ }
+
+ timer = setTimeout(poll, pollIntervalMs);
+ };
+
+ timer = setTimeout(poll, pollIntervalMs);
+ });
+ }
+}