diff --git a/src/services/dmr-client.ts b/src/services/dmr-client.ts new file mode 100644 index 0000000..da7f8d5 --- /dev/null +++ b/src/services/dmr-client.ts @@ -0,0 +1,137 @@ +import type { + DMRChatChunk, + DMRChatRequest, + Model, + StreamChunk, + ToolDefinition, +} from "../types"; + +const DMR_BASE = "http://localhost:12434/engines/v1"; + +export async function listModels(): Promise { + try { + const res = await fetch(`${DMR_BASE}/models`); + const data: unknown = await res.json(); + const typed = data as { data?: { id: string }[] }; + return (typed.data ?? []).map((m) => ({ + id: m.id, + name: m.id.split("/").pop() ?? m.id, + })); + } catch { + return []; + } +} + +interface StreamToolCallAcc { + id: string; + name: string; + argsBuffer: string; +} + +export async function* streamChat( + messages: { + role: string; + content: string; + tool_call_id?: string; + name?: string; + }[], + model: string, + tools?: ToolDefinition[], + signal?: AbortSignal, +): AsyncGenerator { + const body: DMRChatRequest & { + tools?: { + type: "function"; + function: { name: string; description: string; parameters: unknown }; + }[]; + } = { + model, + messages, + stream: true, + }; + + if (tools && tools.length > 0) { + body.tools = tools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + })); + } + + const res = await fetch(`${DMR_BASE}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + + if (!res.ok) { + throw new Error(`DMR error: ${res.status} ${res.statusText}`); + } + + const reader = res.body?.getReader(); + if (!reader) throw new Error("No readable stream"); + const decoder = new TextDecoder(); + let buffer = ""; + + const toolCallAccumulators = new Map(); + + while (true) { + if (signal?.aborted) { + await reader.cancel().catch(() => {}); + return; + } + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data: ")) continue; + + const data = trimmed.slice(6); + if (data === "[DONE]") return; + + try { + const chunk = JSON.parse(data) as DMRChatChunk; + const delta = chunk.choices?.[0]?.delta; + if (!delta) continue; + + if (delta.reasoning_content) { + yield { type: "thinking", text: delta.reasoning_content }; + } + if (delta.content) { + yield { type: "content", text: delta.content }; + } + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = delta.tool_calls.indexOf(tc); + let acc = toolCallAccumulators.get(idx); + if (!acc) { + acc = { id: tc.id ?? "", name: tc.function.name, argsBuffer: "" }; + toolCallAccumulators.set(idx, acc); + } + if (tc.id) acc.id = tc.id; + if (tc.function.name) acc.name = tc.function.name; + acc.argsBuffer += tc.function.arguments; + + yield { + type: "tool_call", + id: acc.id, + name: acc.name, + arguments: tc.function.arguments, + }; + } + } + } catch { + // skip malformed chunks + } + } + } +} diff --git a/src/services/model-registry.ts b/src/services/model-registry.ts new file mode 100644 index 0000000..0e659d8 --- /dev/null +++ b/src/services/model-registry.ts @@ -0,0 +1,31 @@ +import type { Model } from "../types"; +import { listModels } from "./dmr-client"; + +let cachedModels: Model[] = []; +let currentIndex = 0; + +export async function fetchModels(): Promise { + cachedModels = await listModels(); + return cachedModels; +} + +export function nextModel(): Model | null { + if (cachedModels.length === 0) return null; + currentIndex = (currentIndex + 1) % cachedModels.length; + return cachedModels[currentIndex] ?? null; +} + +export function prevModel(): Model | null { + if (cachedModels.length === 0) return null; + currentIndex = (currentIndex - 1 + cachedModels.length) % cachedModels.length; + return cachedModels[currentIndex] ?? null; +} + +export function setModelById(id: string): Model | null { + const idx = cachedModels.findIndex((m) => m.id === id); + if (idx >= 0) { + currentIndex = idx; + return cachedModels[idx] ?? null; + } + return null; +} diff --git a/src/services/openspec-hook.ts b/src/services/openspec-hook.ts new file mode 100644 index 0000000..5483307 --- /dev/null +++ b/src/services/openspec-hook.ts @@ -0,0 +1,68 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Step } from "../types"; + +const WORKFLOW_STEPS = [ + { id: "discuss", name: "Discuss" }, + { id: "propose", name: "Propose" }, + { id: "plan", name: "Plan" }, + { id: "design", name: "Design" }, + { id: "tasks", name: "Tasks" }, + { id: "apply", name: "Apply" }, + { id: "archive", name: "Archive" }, +]; + +export function detectOpenSpecSteps(projectDir: string): Step[] { + const changesDir = join(projectDir, "openspec", "changes"); + + if (!existsSync(changesDir)) { + return WORKFLOW_STEPS.map((s, i) => ({ + ...s, + status: (i === 0 ? "current" : "pending") as const, + })); + } + + const changes = readdirSync(changesDir).filter((f) => !f.startsWith(".")); + if (changes.length === 0) { + return WORKFLOW_STEPS.map((s, i) => ({ + ...s, + status: (i === 0 ? "current" : "pending") as const, + })); + } + + const latestChange = join(changesDir, changes[changes.length - 1] ?? ""); + const hasProposal = existsSync(join(latestChange, "proposal.md")); + const hasSpecs = existsSync(join(latestChange, "specs")); + const hasDesign = existsSync(join(latestChange, "design.md")); + const hasTasks = existsSync(join(latestChange, "tasks.md")); + + return [ + { + id: "discuss", + name: "Discuss", + status: hasProposal ? "done" : "current", + }, + { + id: "propose", + name: "Propose", + status: hasProposal ? "done" : hasSpecs ? "current" : "pending", + }, + { + id: "plan", + name: "Plan", + status: hasSpecs ? "done" : hasProposal ? "current" : "pending", + }, + { + id: "design", + name: "Design", + status: hasDesign ? "done" : hasSpecs ? "current" : "pending", + }, + { + id: "tasks", + name: "Tasks", + status: hasTasks ? "done" : hasDesign ? "current" : "pending", + }, + { id: "apply", name: "Apply", status: "pending" }, + { id: "archive", name: "Archive", status: "pending" }, + ]; +} diff --git a/src/services/openspec-setup.ts b/src/services/openspec-setup.ts new file mode 100644 index 0000000..3c092af --- /dev/null +++ b/src/services/openspec-setup.ts @@ -0,0 +1,26 @@ +import { execSync } from "node:child_process"; + +export async function installOpenSpec(projectDir: string): Promise { + try { + // Install openspec globally if not present + try { + execSync("openspec --version", { stdio: "ignore" }); + } catch { + execSync("npm install -g @fission-ai/openspec@latest", { + stdio: "ignore", + }); + } + + // Initialize openspec in the project + execSync("openspec init --force --tools none", { + cwd: projectDir, + stdio: "ignore", + }); + } catch (_err) { + console.warn( + "Warning: OpenSpec installation failed. You can install it manually:", + ); + console.warn(" npm install -g @fission-ai/openspec@latest"); + console.warn(" cd your-project && openspec init"); + } +} diff --git a/src/services/openspec-sync.ts b/src/services/openspec-sync.ts new file mode 100644 index 0000000..cb300fb --- /dev/null +++ b/src/services/openspec-sync.ts @@ -0,0 +1,68 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join, relative } from "node:path"; + +const IGNORED_DIRS = new Set(["node_modules", ".git", ".paikea"]); +const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]); + +export function syncOpenSpecToVault(projectDir: string): void { + const openspecDir = join(projectDir, "openspec"); + const vaultDir = join(projectDir, ".paikea", "vault", "openspec"); + + if (!existsSync(openspecDir)) return; + + mkdirSync(vaultDir, { recursive: true }); + syncDir(openspecDir, vaultDir, projectDir); +} + +function syncDir(source: string, target: string, projectRoot: string): void { + mkdirSync(target, { recursive: true }); + + const entries = readdirSync(source, { withFileTypes: true }); + for (const entry of entries) { + if (IGNORED_DIRS.has(entry.name) || IGNORED_FILES.has(entry.name)) continue; + + const srcPath = join(source, entry.name); + const tgtPath = join(target, entry.name); + + if (entry.isDirectory()) { + syncDir(srcPath, tgtPath, projectRoot); + } else if (entry.name.endsWith(".md") || entry.name.endsWith(".json")) { + const srcStat = statSync(srcPath); + const needsUpdate = + !existsSync(tgtPath) || statSync(tgtPath).mtimeMs < srcStat.mtimeMs; + + if (needsUpdate) { + const content = readFileSync(srcPath, "utf-8"); + const relPath = relative(projectRoot, srcPath); + const enriched = addFrontmatter(content, relPath); + writeFileSync(tgtPath, enriched); + } + } + } +} + +function addFrontmatter(content: string, filePath: string): string { + if (content.startsWith("---")) return content; + + const now = new Date().toISOString().split("T")[0] ?? ""; + const type = filePath.includes("/specs/") ? "spec" : "change"; + const title = filePath.split("/").pop()?.replace(/\.md$/, "") ?? filePath; + + const frontmatter = `--- +date: ${now} +tags: [openspec, ${type}] +type: resource +status: active +title: ${title} +source: ${filePath} +--- +`; + return frontmatter + content; +} diff --git a/src/services/suggestions.ts b/src/services/suggestions.ts new file mode 100644 index 0000000..7e12801 --- /dev/null +++ b/src/services/suggestions.ts @@ -0,0 +1,67 @@ +const STEP_SUGGESTIONS: Record = { + discuss: [ + "Let's explore the problem space", + "What are the tradeoffs here?", + "Show me the current architecture", + "What are the constraints?", + "What are the edge cases?", + ], + propose: [ + "Create a proposal for", + "I want to add", + "I need to change", + "The problem is", + "We should refactor", + ], + plan: [ + "Break this down into steps", + "What files need to change?", + "What's the impact on the codebase?", + "What are the dependencies?", + "List the affected modules", + ], + design: [ + "Design the API for", + "What's the data model?", + "How should this integrate?", + "What's the interface?", + "Sketch the architecture", + ], + tasks: [ + "Create tasks for", + "What's the priority order?", + "Estimate the effort", + "Break into subtasks", + "What can be parallelized?", + ], + apply: [ + "Start implementing", + "Apply the changes", + "Run the tests", + "What's left to do?", + "Verify the implementation", + ], + archive: [ + "Archive this change", + "What did we learn?", + "Update the documentation", + "Create a summary", + "What should we remember?", + ], +}; + +export function computeSuggestions(prompt: string, stepId: string): string[] { + const candidates = STEP_SUGGESTIONS[stepId] ?? STEP_SUGGESTIONS.discuss ?? []; + + if (!prompt) return candidates; + + const lower = prompt.toLowerCase(); + return candidates.filter((s) => s.toLowerCase().startsWith(lower)); +} + +export function getCurrentStepId( + steps: { id: string; status: string }[], +): string { + const current = steps.find((s) => s.status === "current"); + return current?.id ?? "discuss"; +} diff --git a/src/services/thinking-parser.ts b/src/services/thinking-parser.ts new file mode 100644 index 0000000..ee394b3 --- /dev/null +++ b/src/services/thinking-parser.ts @@ -0,0 +1,11 @@ +export function hasThinkingSupport(modelId: string): boolean { + const thinkingModels = [ + "qwen3", + "deepseek-r1", + "deepseek-reasoner", + "o1", + "o3", + ]; + const lower = modelId.toLowerCase(); + return thinkingModels.some((m) => lower.includes(m)); +}