Move docs to lemonade

This commit is contained in:
larchanka
2026-03-09 14:37:13 +01:00
committed by Mikhail Larchanka
parent 9b02cd5f43
commit e9bd04b4f6
28 changed files with 1175 additions and 929 deletions

View File

@@ -1,20 +1,20 @@
# FP-04: Extend OllamaAdapter with Vision/Image Support
# FP-04: Extend LemonadeAdapter with Vision/Image Support
**File**: `src/services/ollama-adapter.ts`
**File**: `src/services/lemonade-adapter.ts`
**Dependencies**: FP-02, FP-03
**Phase**: 2 — Core Services
## Description
Add a new `chatWithImage()` method to `OllamaAdapter` that accepts a local image file path, reads and base64-encodes it, and sends it to the Ollama `/api/chat` endpoint using the multimodal `images` field. This is required by the `file-processor` to call `glm-ocr:q8_0` for image OCR.
Add a new `chatWithImage()` method to `LemonadeAdapter` that accepts a local image file path, reads and base64-encodes it, and sends it to the Lemonade `/chat/completions` endpoint as an OpenAI-compatible vision request. This is required by the `file-processor` to call `qwen3-vl` for image OCR.
## Acceptance Criteria
- New method signature:
```ts
async chatWithImage(
messages: OllamaMessage[],
messages: ChatMessage[],
model: string,
imagePath: string
): Promise<OllamaResponse>
): Promise<ChatResult>
```
- Method reads the file at `imagePath` using `fs/promises.readFile`
- Encodes to base64 string
@@ -23,7 +23,7 @@ Add a new `chatWithImage()` method to `OllamaAdapter` that accepts a local image
- Throws a clear error if `imagePath` does not exist or cannot be read
## Implementation Notes
- Ollama vision format: the `images` field is an array of base64 strings, placed in the user message object
- No MIME type needed — Ollama auto-detects from content
- OpenAI vision format: the `images_url` field is part of the message content array.
- MIME type is needed for the data URI.
- Reuse existing `fetchWithTimeout` / retry logic from `chat()` — don't duplicate
- Add a unit test stub (can be skipped/mocked) confirming the base64 encoding step

View File

@@ -14,7 +14,7 @@ Create the `file-processor` as an independent `BaseProcess` subprocess. It liste
- `category: 'text'` → read file with `fs/promises.readFile` (UTF-8)
- If content.length <= `textMaxInlineChars`: respond with `type: 'text'`, full content
- If content.length > `textMaxInlineChars`: respond with `type: 'text_long'`, full content (chunking/summarizing is Orchestrator's responsibility — see FP-10)
- `category: 'image'` and `ocrEnabled: true` → call `OllamaAdapter.chatWithImage()` with OCR prompt; respond with `type: 'image_ocr'`
- `category: 'image'` and `ocrEnabled: true` → call `LemonadeAdapter.chatWithImage()` with OCR prompt; respond with `type: 'image_ocr'`
- `category: 'audio'` → call `convertToWav()` then `transcribeAudio()`; respond with `type: 'audio_transcript'`
- `category: 'unknown'` → respond with `type: 'ignored'`, empty content
- After any processing attempt (success or failure), **delete the original file** from disk

View File

@@ -29,7 +29,7 @@ Manual end-to-end verification of the complete file processing pipeline across a
- [ ] Send file + caption → caption is used as the goal, file is used as context
### Failure Recovery
- [ ] Temporarily disable Ollama → image file sends user an error warning, pipeline continues if other files succeeded
- [ ] Temporarily disable Lemonade → image file sends user an error warning, pipeline continues if other files succeeded
- [ ] Send corrupt audio → transcription error is reported silently, pipeline continues
## Implementation Notes

View File

@@ -1,7 +1,7 @@
# LM-04: Update Core Services for Lemonade
## Description
Convert `GeneratorService`, `RAGService`, and `TimeParserService` from using `OllamaAdapter` to `LemonadeAdapter` for general text and embedding tasks.
Convert `GeneratorService`, `RAGService`, and `TimeParserService` from using a legacy adapter to `LemonadeAdapter` for general text and embedding tasks.
## Status
- [x] Update `GeneratorService` to use Lemonade's chat endpoint.

View File

@@ -1,11 +1,11 @@
# M1-01: Enhance OllamaAdapter with Warmup Support
# M1-01: Enhance LemonadeAdapter with Warmup Support
**File**: `src/services/ollama-adapter.ts`
**File**: `src/services/lemonade-adapter.ts`
**Dependencies**: None
**Phase**: M1 - Core Infrastructure
## Description
Add a `warmup` method to `OllamaAdapter` that uses the `/api/chat` endpoint with a minimal prompt and supports the `keep_alive` parameter.
Add a `warmup` method to `LemonadeAdapter` that sends a minimal prompt to ensure the model is loaded into VRAM.
## Acceptance Criteria
- `warmup(model: string, keepAlive: string | number): Promise<void>` implemented.

View File

@@ -14,6 +14,6 @@ Create comprehensive unit tests for the `ModelManagerService`.
- All tests pass with `npm test`.
## Implementation Notes
- Mock `OllamaAdapter` using `vi.fn()`.
- Mock `LemonadeAdapter` using `vi.fn()`.
- Use `vi.useFakeTimers()` if needed to test timeouts/delays.
- Verify the number of calls to `ollama.warmup`.
- Verify the number of calls to `lemonade.warmup`.

View File

@@ -9,7 +9,7 @@ Update `GeneratorService` to call the `ModelManagerService` before performing an
## Acceptance Criteria
- `ModelManagerService` injected into `GeneratorService`.
- `ensureModelLoaded` called before `ollama.chat` and `ollama.generate`.
- `ensureModelLoaded` called before `lemonade.chat`.
- Works correctly with all model tiers.
## Implementation Notes

View File

@@ -8,7 +8,7 @@
Integration test to verify that inference requests correctly trigger model loading.
## Acceptance Criteria
- Mock Ollama adapter.
- Mock Lemonade adapter.
- Verify `GeneratorService` + `ModelManagerService` interaction.
- All tests pass with `npm test`.

View File

@@ -11,7 +11,7 @@ Set up the base directory structure, initialize the Node.js project, and configu
- `src/` (source code)
- `src/core/` (orchestrator)
- `src/agents/` (planner, executor, critic)
- `src/services/` (memory, logging, ollama)
- `src/services/` (memory, logging, lemonade)
- `src/shared/` (types, protocol)
- `src/adapters/` (telegram)

View File

@@ -11,7 +11,7 @@ Conduct a verification run to ensure that the complexity mapping works as expect
- [ ] Set `plannerComplexity` to `large` in `config.json`.
- [ ] Run a task that requires multiple steps (e.g., "Write a complex research paper about black holes").
- [ ] Check terminal logs for IPC messages to `model-router`.
- [ ] Verify that the `model` parameter in Ollama requests is `qwen3:8b` (mapped to `large`).
- [ ] Verify that the `model` parameter in Lemonade requests is `qwen2.5:7b` (mapped to `large`).
## Success Criteria
- Planning uses the configured complexity.

View File

@@ -38,7 +38,7 @@ All code changes for P10-04 and P10-05 have been implemented:
5. **Verify in logs**:
- Look for `GeneratorService` IPC messages
- Check that `model` parameter in Ollama requests is `qwen3:8b` for large complexity nodes
- Check that `model` parameter in Lemonade requests is `qwen2.5:7b` for large complexity nodes
- Verify fallback logic works: nodes with explicit `modelClass` use that, others use plan complexity
## Success Criteria
@@ -54,6 +54,6 @@ The verification requires manual testing as it involves:
- Running the orchestrator
- Sending tasks via Telegram
- Observing IPC message logs
- Verifying model selection in Ollama requests
- Verifying model selection in Lemonade requests
All code changes are complete and the system is ready for verification testing.

View File

@@ -1,11 +1,11 @@
# Task: P2-01 Implement Ollama Adapter
# Task: P2-01 Implement Lemonade Adapter
## Description
Build a bridge between the ManBot platform and the local Ollama instance. This adapter will handle model inference, streaming responses, and token usage reporting.
Build a bridge between the ManBot platform and the local Lemonade instance. This adapter will handle model inference, streaming responses, and token usage reporting in an OpenAI-compatible way.
## Requirements
- Create `src/services/ollama-adapter.ts`.
- Implement a client using `fetch` or the `@ollama/ollama` JS library.
- Create `src/services/lemonade-adapter.ts`.
- Implement a client using `fetch` or the `openai` JS library (or raw fetch for OpenAI compatibility).
- Support essential methods:
- `generate(prompt, model)`: returns full response.
- `chat(messages, model)`: returns full response.
@@ -14,6 +14,6 @@ Build a bridge between the ManBot platform and the local Ollama instance. This a
- Implement timeout handling and retry logic for network errors.
## Definition of Done
- Adapter correctly retrieves completions from a running Ollama server.
- Adapter correctly retrieves completions from a running Lemonade server.
- Supported methods handle streaming and regular responses accurately.
- Token metrics are extracted from the Ollama response payload.
- Token metrics are extracted from the Lemonade response payload.

View File

@@ -1,7 +1,7 @@
# Task: P2-02 Implement Model Router
## Description
Create a service that maps abstract task complexity levels (`small`, `medium`, `large`) to specific local model names in Ollama.
Create a service that maps abstract task complexity levels (`small`, `medium`, `large`) to specific local model names in Lemonade.
## Requirements
- Create `src/services/model-router.ts`.

View File

@@ -6,7 +6,7 @@ Implement the Critic Agent as a standalone process that receives execution resul
## Requirements
- Create `src/agents/critic-agent.ts`.
- Extend `BaseProcess`.
- Integration with Ollama Adapter and Model Router.
- Integration with Lemonade Adapter and Model Router.
- Accepts a `reflection.evaluate` request containing the task context and draft result.
- Returns a structured evaluation response.

View File

@@ -9,7 +9,7 @@ Implement a Retrieval-Augmented Generation (RAG) service using the FAISS vector
- Implement methods:
- `addDocument(content, metadata)`: Embeds and stores a document.
- `search(query, limit)`: Returns relevant snippets based on semantic similarity.
- Use a dedicated embedding model (e.g., `nomic-embed-text`) via the Ollama Adapter.
- Use a dedicated embedding model (e.g., `text-embedding-v3`) via the Lemonade Adapter.
## Definition of Done
- RAG Service can index text and retrieve relevant matches for a given query.

View File

@@ -4,7 +4,7 @@
Write an integration test that verifies the full conversation archiving flow.
## Requirements
- Mock Ollama for the summarization step.
- Mock Lemonade for the summarization step.
- Verify that `chat.new` triggers task history retrieval.
- Verify that the summary is correctly inserted into the `RAGService`.
- Verify that the SQLite database contains the expected archived record.

View File

@@ -9,3 +9,4 @@
| research | PRIMARY SEARCH. Use for deep web research, fact-checking, news gathering, or topical deep dives via the lynx tool. This is the default skill for any query requiring external or up-to-date information. |
| reminder | SCHEDULING. Use this skill exclusively to set one-time or recurring reminders (e.g., "remind me in 2 hours"). This is the only tool that interfaces with the cron-manager service. |
| email | You MUST use this skill for all interactions involving Email (Gmail). |
| calendar | You MUST use this skill for all interactions involving Google Calendar. |

245
skills/calendar/SKILL.md Normal file
View File

@@ -0,0 +1,245 @@
# Calendar Skill
Manage Google Calendar events using the `gog` CLI.
Mandatory tool: Use the `shell` tool to execute `gog` commands.
## When to Use
**USE this skill when:**
- Checking upcoming meetings or daily schedules.
- Searching for calendar events by title or keyword.
- Creating, updating, or deleting calendar events.
- Checking availability or scheduling conflicts.
- Responding to meeting invitations (accept/decline/tentative).
- Reviewing team calendars or free/busy blocks.
## When NOT to Use
**DON'T use this skill when:**
- Managing Gmail messages (use the Email Skill).
- Editing Google Contacts.
- Sending meeting notes via email (unless specifically requested).
## Commands
### 📅 Viewing Events
```bash
# Today's events
gog calendar events primary --today
# Tomorrow's events
gog calendar events primary --tomorrow
# Events for the week
gog calendar events primary --week
# Next 3 days
gog calendar events primary --days 3
# Specific date range
gog calendar events primary --from today --to friday
# Fetch events across all calendars
gog calendar events --all
# Fetch events from specific calendars
gog calendar events --cal Work --cal Personal
```
### 🔎 Searching Events
```bash
# Search events by keyword
gog calendar search "meeting"
# Search within time windows
gog calendar search "standup" --today
gog calendar search "planning" --week
gog calendar search "demo" --days 365
# Custom date range
gog calendar search "conference" \
--from 2025-01-01T00:00:00Z \
--to 2025-01-31T00:00:00Z \
--max 50
```
### 📌 Event Details
```bash
# Get a specific event
gog calendar get <calendarId> <eventId>
# JSON output for structured parsing
gog calendar get <calendarId> <eventId> --json
```
### Creating Events
```bash
# Simple meeting
gog calendar create primary \
--summary "Meeting" \
--from 2025-01-15T10:00:00Z \
--to 2025-01-15T11:00:00Z
# Meeting with attendees
gog calendar create primary \
--summary "Team Sync" \
--from 2025-01-15T14:00:00Z \
--to 2025-01-15T15:00:00Z \
--attendees "alice@example.com,bob@example.com" \
--location "Zoom"
```
### ✏️ Updating Events
```bash
# Update event title and time
gog calendar update <calendarId> <eventId> \
--summary "Updated Meeting" \
--from 2025-01-15T11:00:00Z \
--to 2025-01-15T12:00:00Z
# Add attendees without replacing existing ones
gog calendar update <calendarId> <eventId> \
--add-attendee "alice@example.com,bob@example.com"
```
### ❌ Deleting Events
```bash
gog calendar delete <calendarId> <eventId>
```
### 📩 Invitations
```bash
# Accept meeting
gog calendar respond <calendarId> <eventId> --status accepted
# Decline meeting
gog calendar respond <calendarId> <eventId> --status declined
# Tentative
gog calendar respond <calendarId> <eventId> --status tentative
```
### 🕒 Availability & Conflicts
```bash
# Check free/busy time
gog calendar freebusy \
--calendars "primary,work@example.com" \
--from 2025-01-15T00:00:00Z \
--to 2025-01-16T00:00:00Z
# Check conflicts
gog calendar conflicts --today
# Conflicts across all calendars
gog calendar conflicts --all --today
```
### 👥 Team Calendars
```bash
# Team events today
gog calendar team team@example.com --today
# Team schedule this week
gog calendar team team@example.com --week
# Free/busy overview
gog calendar team team@example.com --freebusy
# Filter by title
gog calendar team team@example.com --query "standup"
```
### 🧠 Special Event Types
```bash
# Focus time
gog calendar focus-time \
--from 2025-01-15T13:00:00Z \
--to 2025-01-15T14:00:00Z
# Out of office
gog calendar out-of-office \
--from 2025-01-20 \
--to 2025-01-21 \
--all-day
# Working location
gog calendar working-location \
--type office \
--office-label "HQ" \
--from 2025-01-22 \
--to 2025-01-23
```
### Tool Call Examples (JSON)
When using this skill, format your tool calls as follows:
Get Today's Events:
```json
{
"name": "shell",
"arguments": {
"command": "gog calendar events primary --today"
}
}
```
Search for an Event
```json
{
"name": "shell",
"arguments": {
"command": "gog calendar search \"meeting\" --today"
}
}
```
Create a Meeting
```json
{
"name": "shell",
"arguments": {
"command": "gog calendar create primary --summary \"Team Sync\" --from \"2025-01-15T14:00:00Z\" --to \"2025-01-15T15:00:00Z\""
}
}
```
Accept Invitation
```json
{
"name": "shell",
"arguments": {
"command": "gog calendar respond primary \"event-123\" --status accepted"
}
}
```
Delete Event
```json
{
"name": "shell",
"arguments": {
"command": "gog calendar delete primary \"event-123\""
}
}
```
## Notes
- Confirmation: Always confirm the event summary, date/time, and attendees before creating, updating, or deleting an event.
- Calendar IDs: primary refers to the user's main calendar; other calendars can be referenced by name or email.
- Timezone Awareness: The CLI supports timezone-aware timestamps; prefer ISO timestamps (e.g., 2025-01-15T14:00:00Z).
- JSON Output: Use the --json flag when structured event details are required for parsing.

View File

@@ -15,112 +15,112 @@ import { TaskMemoryStore } from "../services/task-memory.js";
const TEST_DIR = join(process.cwd(), "data", "test-archiving-" + randomUUID());
function freshDbPath(prefix: string): string {
return join(TEST_DIR, `${prefix}-${randomUUID()}.sqlite`);
return join(TEST_DIR, `${prefix}-${randomUUID()}.sqlite`);
}
/** Fixed 4-dim embedding for test (L2-normalized). */
function norm(v: number[]): number[] {
const len = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
return v.map((x) => x / len);
const len = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
return v.map((x) => x / len);
}
describe("Archiving pipeline integration", () => {
let taskDbPath: string;
let ragDbPath: string;
let taskStore: TaskMemoryStore;
let ragStore: RAGStore;
let taskDbPath: string;
let ragDbPath: string;
let taskStore: TaskMemoryStore;
let ragStore: RAGStore;
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
taskDbPath = freshDbPath("tasks");
ragDbPath = freshDbPath("rag");
taskStore = new TaskMemoryStore(taskDbPath);
ragStore = new RAGStore(ragDbPath, 4); // 4-dim for test vectors
});
afterEach(() => {
taskStore.close();
ragStore.close();
try {
rmSync(TEST_DIR, { recursive: true, force: true });
} catch {
// ignore
}
});
it("chat.new flow: task history retrieval, mock summarization, RAG insert, SQLite contains record", () => {
const conversationId = "conv-" + randomUUID();
const taskId1 = "t1-" + randomUUID();
const taskId2 = "t2-" + randomUUID();
// 1. Create tasks in task-memory with same conversation_id (simulate prior conversation)
taskStore.createTaskWithDag({
taskId: taskId1,
conversationId,
goal: "Explain TypeScript benefits",
nodes: [
{ id: "n1", type: "generate_text", service: "model-router", input: {} },
],
edges: [],
});
taskStore.updateNodeStatus(taskId1, "n1", "completed", {
output: "TypeScript adds static types and better tooling.",
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
taskDbPath = freshDbPath("tasks");
ragDbPath = freshDbPath("rag");
taskStore = new TaskMemoryStore(taskDbPath);
ragStore = new RAGStore(ragDbPath, 4); // 4-dim for test vectors
});
taskStore.createTaskWithDag({
taskId: taskId2,
conversationId,
goal: "Summarize the previous answer",
nodes: [
{ id: "n2", type: "generate_text", service: "model-router", input: {} },
],
edges: [],
});
taskStore.updateNodeStatus(taskId2, "n2", "completed", {
output: "User learned about TypeScript benefits.",
afterEach(() => {
taskStore.close();
ragStore.close();
try {
rmSync(TEST_DIR, { recursive: true, force: true });
} catch {
// ignore
}
});
// 2. Retrieve task history by conversation_id (simulate orchestrator step)
const tasks = taskStore.getTasksByConversationId(conversationId);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.goal)).toContain("Explain TypeScript benefits");
expect(tasks.map((t) => t.goal)).toContain("Summarize the previous answer");
it("chat.new flow: task history retrieval, mock summarization, RAG insert, SQLite contains record", () => {
const conversationId = "conv-" + randomUUID();
const taskId1 = "t1-" + randomUUID();
const taskId2 = "t2-" + randomUUID();
// 3. Format history and "summarize" (mock: no Ollama)
const historyParts: string[] = [];
for (const t of tasks) {
const task = taskStore.getTask(t.id) as { nodes?: Array<{ output?: string }> } | null;
const nodes = task?.nodes ?? [];
const lastOutput = nodes.filter((n) => n.output != null && n.output !== "").pop()?.output ?? "";
historyParts.push(`Goal: ${t.goal}\nResult: ${lastOutput || "(no output)"}`);
}
const chatHistory = historyParts.join("\n\n---\n\n");
expect(chatHistory).toContain("TypeScript");
const mockSummary = "User preferences: TypeScript. Context: learning about static typing and tooling.";
// 1. Create tasks in task-memory with same conversation_id (simulate prior conversation)
taskStore.createTaskWithDag({
taskId: taskId1,
conversationId,
goal: "Explain TypeScript benefits",
nodes: [
{ id: "n1", type: "generate_text", service: "model-router", input: {} },
],
edges: [],
});
taskStore.updateNodeStatus(taskId1, "n1", "completed", {
output: "TypeScript adds static types and better tooling.",
});
// 4. Store summary in RAG (simulate memory.semantic.insert; use dummy embedding)
const docId = randomUUID();
const embedding = norm([0.5, 0.5, 0.5, 0.5]);
const metadata = { conversationId, chatId: 12345, archivedAt: Date.now(), source: "archiving" };
ragStore.insert(docId, mockSummary, metadata, embedding);
taskStore.createTaskWithDag({
taskId: taskId2,
conversationId,
goal: "Summarize the previous answer",
nodes: [
{ id: "n2", type: "generate_text", service: "model-router", input: {} },
],
edges: [],
});
taskStore.updateNodeStatus(taskId2, "n2", "completed", {
output: "User learned about TypeScript benefits.",
});
// 5. Verify RAG retrieval and SQLite contain the archived record
const results = ragStore.search(embedding, 5);
expect(results).toHaveLength(1);
expect(results[0]?.content).toBe(mockSummary);
expect(results[0]?.metadata).toMatchObject({ conversationId, source: "archiving" });
// 2. Retrieve task history by conversation_id (simulate orchestrator step)
const tasks = taskStore.getTasksByConversationId(conversationId);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.goal)).toContain("Explain TypeScript benefits");
expect(tasks.map((t) => t.goal)).toContain("Summarize the previous answer");
// 6. Verify SQLite rag_documents has the row
const db = new Database(ragDbPath);
const row = db.prepare("SELECT id, content, metadata FROM rag_documents WHERE id = ?").get(docId) as
| { id: string; content: string; metadata: string }
| undefined;
db.close();
expect(row).toBeDefined();
expect(row?.id).toBe(docId);
expect(row?.content).toBe(mockSummary);
const meta = JSON.parse(row?.metadata ?? "{}") as Record<string, unknown>;
expect(meta.conversationId).toBe(conversationId);
expect(meta.source).toBe("archiving");
});
// 3. Format history and "summarize" (mock: no Ollama)
const historyParts: string[] = [];
for (const t of tasks) {
const task = taskStore.getTask(t.id) as { nodes?: Array<{ output?: string }> } | null;
const nodes = task?.nodes ?? [];
const lastOutput = nodes.filter((n) => n.output != null && n.output !== "").pop()?.output ?? "";
historyParts.push(`Goal: ${t.goal}\nResult: ${lastOutput || "(no output)"}`);
}
const chatHistory = historyParts.join("\n\n---\n\n");
expect(chatHistory).toContain("TypeScript");
const mockSummary = "User preferences: TypeScript. Context: learning about static typing and tooling.";
// 4. Store summary in RAG (simulate memory.semantic.insert; use dummy embedding)
const docId = randomUUID();
const embedding = norm([0.5, 0.5, 0.5, 0.5]);
const metadata = { conversationId, chatId: 12345, archivedAt: Date.now(), source: "archiving" };
ragStore.insert(docId, mockSummary, metadata, embedding);
// 5. Verify RAG retrieval and SQLite contain the archived record
const results = ragStore.search(embedding, 5);
expect(results).toHaveLength(1);
expect(results[0]?.content).toBe(mockSummary);
expect(results[0]?.metadata).toMatchObject({ conversationId, source: "archiving" });
// 6. Verify SQLite rag_documents has the row
const db = new Database(ragDbPath);
const row = db.prepare("SELECT id, content, metadata FROM rag_documents WHERE id = ?").get(docId) as
| { id: string; content: string; metadata: string }
| undefined;
db.close();
expect(row).toBeDefined();
expect(row?.id).toBe(docId);
expect(row?.content).toBe(mockSummary);
const meta = JSON.parse(row?.metadata ?? "{}") as Record<string, unknown>;
expect(meta.conversationId).toBe(conversationId);
expect(meta.source).toBe("archiving");
});
});

View File

@@ -18,131 +18,131 @@ import { ConsoleLogger } from "../utils/console-logger.js";
const PLAN_CREATE = "plan.create";
interface PlanCreatePayload {
goal?: string;
message?: string;
complexity?: "small" | "medium" | "large";
/** When set, a previous attempt failed; use this to produce a corrected plan. */
previousError?: string;
/** Optional previous plan that failed (object). Will be stringified for the prompt. */
previousPlan?: Record<string, unknown>;
/** Optional conversation history for context. */
history?: string;
goal?: string;
message?: string;
complexity?: "small" | "medium" | "large";
/** When set, a previous attempt failed; use this to produce a corrected plan. */
previousError?: string;
/** Optional previous plan that failed (object). Will be stringified for the prompt. */
previousPlan?: Record<string, unknown>;
/** Optional conversation history for context. */
history?: string;
}
function extractJson(text: string): string {
let s = text.trim();
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
s = s.slice(start, end + 1);
}
return s;
let s = text.trim();
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
s = s.slice(start, end + 1);
}
return s;
}
export class PlannerAgent extends BaseProcess {
private readonly lemonade: LemonadeAdapter;
private readonly modelRouter: ModelRouter;
private readonly skillManager: SkillManager;
private readonly lemonade: LemonadeAdapter;
private readonly modelRouter: ModelRouter;
private readonly skillManager: SkillManager;
constructor(options?: { lemonade?: LemonadeAdapter; modelRouter?: ModelRouter; skillManager?: SkillManager }) {
super({ processName: "planner" });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
this.skillManager = options?.skillManager ?? new SkillManager();
}
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.type !== PLAN_CREATE) return;
const payload = envelope.payload as Record<string, unknown>;
const p = payload as unknown as PlanCreatePayload;
const goal = p.goal ?? p.message ?? "";
const complexity = p.complexity ?? "medium";
if (!goal || typeof goal !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "plan.create requires goal or message");
return;
constructor(options?: { lemonade?: LemonadeAdapter; modelRouter?: ModelRouter; skillManager?: SkillManager }) {
super({ processName: "planner" });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
this.skillManager = options?.skillManager ?? new SkillManager();
}
(async () => {
try {
const model = this.modelRouter.getModel(complexity);
const previousError = typeof p.previousError === "string" ? p.previousError : undefined;
const previousPlanJson =
p.previousPlan && typeof p.previousPlan === "object"
? JSON.stringify(p.previousPlan, null, 2)
: undefined;
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.type !== PLAN_CREATE) return;
// Load dynamic skills
const skills = this.skillManager.listSkills();
const payload = envelope.payload as Record<string, unknown>;
const p = payload as unknown as PlanCreatePayload;
const goal = p.goal ?? p.message ?? "";
const complexity = p.complexity ?? "medium";
const promptOptions = {
...(previousError && { previousError }),
...(previousPlanJson && { previousPlanJson }),
...(p.history && { conversationHistory: p.history }),
...(skills.length > 0 && { skills }),
};
const prompt = buildPlannerPrompt(goal, promptOptions);
const messages = [
{ role: "system" as const, content: "You output only valid JSON. No markdown, no explanation." },
{ role: "user" as const, content: prompt },
];
const result = await this.lemonade.chat(messages, model);
const raw = result.message?.content ?? "";
ConsoleLogger.debug("planner", `Raw model response (length: ${raw.length})`);
const jsonStr = extractJson(raw);
const dag = JSON.parse(jsonStr) as CapabilityGraph;
if (!dag.nodes || !Array.isArray(dag.nodes)) {
this.sendError(envelope, "INVALID_DAG", "Model output missing nodes array");
return;
if (!goal || typeof goal !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "plan.create requires goal or message");
return;
}
const validation = validateGraph(dag);
if (!validation.valid) {
this.sendError(envelope, "DAG_VALIDATION_FAILED", validation.errors.join("; "));
return;
}
(async () => {
try {
const model = this.modelRouter.getModel(complexity);
const previousError = typeof p.previousError === "string" ? p.previousError : undefined;
const previousPlanJson =
p.previousPlan && typeof p.previousPlan === "object"
? JSON.stringify(p.previousPlan, null, 2)
: undefined;
this.sendResponse(envelope, dag);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.sendError(envelope, "PLANNER_ERROR", message);
}
})();
}
// Load dynamic skills
const skills = this.skillManager.listSkills();
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: "1.0",
timestamp: Date.now(),
payload,
});
}
const promptOptions = {
...(previousError && { previousError }),
...(previousPlanJson && { previousPlanJson }),
...(p.history && { conversationHistory: p.history }),
...(skills.length > 0 && { skills }),
};
const prompt = buildPlannerPrompt(goal, promptOptions);
const messages = [
{ role: "system" as const, content: "You output only valid JSON. No markdown, no explanation." },
{ role: "user" as const, content: prompt },
];
const result = await this.lemonade.chat(messages, model);
const raw = result.message?.content ?? "";
ConsoleLogger.debug("planner", `Raw model response (length: ${raw.length})`);
const jsonStr = extractJson(raw);
const dag = JSON.parse(jsonStr) as CapabilityGraph;
private sendError(request: Envelope, code: string, message: string): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: "1.0",
timestamp: Date.now(),
payload: { code, message, details: {} },
});
}
if (!dag.nodes || !Array.isArray(dag.nodes)) {
this.sendError(envelope, "INVALID_DAG", "Model output missing nodes array");
return;
}
const validation = validateGraph(dag);
if (!validation.valid) {
this.sendError(envelope, "DAG_VALIDATION_FAILED", validation.errors.join("; "));
return;
}
this.sendResponse(envelope, dag);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.sendError(envelope, "PLANNER_ERROR", message);
}
})();
}
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: "1.0",
timestamp: Date.now(),
payload,
});
}
private sendError(request: Envelope, code: string, message: string): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: "1.0",
timestamp: Date.now(),
payload: { code, message, details: {} },
});
}
}
function main(): void {
const agent = new PlannerAgent();
agent.start();
const agent = new PlannerAgent();
agent.start();
}
main();

View File

@@ -6,7 +6,7 @@ import { PROTOCOL_VERSION } from "../../shared/protocol.js";
// Mock dependencies
vi.mock("../../shared/config.js", () => ({
getConfig: () => ({
ollama: { baseUrl: "http://localhost:11434" },
lemonade: { baseUrl: "http://localhost:11434" },
modelRouter: { plannerComplexity: "small" },
taskMemory: { dbPath: ":memory:" },
cron: { dbPath: ":memory:" },

View File

@@ -18,8 +18,8 @@ vi.mock('../../utils/console-logger', () => ({
}));
// We need to mock the adapter and services that Orchestrator initializes in its constructor
vi.mock('../../adapters/ollama-adapter', () => ({
OllamaAdapter: vi.fn().mockImplementation(function () { return {}; }),
vi.mock('../../adapters/lemonade-adapter', () => ({
LemonadeAdapter: vi.fn().mockImplementation(function () { return {}; }),
}));
vi.mock('../model-router', () => ({
@@ -37,7 +37,7 @@ describe('Orchestrator Concurrency', () => {
vi.clearAllMocks();
(getConfig as any).mockReturnValue({
maxConcurrentTasks: 1,
ollama: { baseUrl: 'http://localhost:11434', timeoutMs: 60000 },
lemonade: { baseUrl: 'http://localhost:11434', timeoutMs: 60000 },
modelRouter: { modelTiers: {} },
});
orchestrator = new Orchestrator();

View File

@@ -4,7 +4,7 @@ import { Orchestrator } from "../orchestrator.js";
// Mock dependencies
vi.mock("../../shared/config.js", () => ({
getConfig: () => ({
ollama: { baseUrl: "http://localhost:11434" },
lemonade: { baseUrl: "http://localhost:11434" },
modelRouter: { plannerComplexity: "small" },
taskMemory: { dbPath: ":memory:" },
cron: { dbPath: ":memory:" },

View File

@@ -1,7 +1,7 @@
/**
* Integration tests: GeneratorService + ModelManagerService interaction.
* Verifies that inference requests correctly trigger model loading via
* ModelManagerService.ensureModelLoaded before ollama.generate / ollama.chat.
* ModelManagerService.ensureModelLoaded before lemonade.chat.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";

View File

@@ -19,227 +19,227 @@ const NODE_EXECUTE = "node.execute";
const PROCESS_NAME = "model-router";
interface NodeExecutePayload {
taskId: string;
nodeId: string;
type: string;
service: string;
input: Record<string, unknown>;
context?: Record<string, unknown>;
taskId: string;
nodeId: string;
type: string;
service: string;
input: Record<string, unknown>;
context?: Record<string, unknown>;
}
export class GeneratorService extends BaseProcess {
private readonly lemonade: LemonadeAdapter;
private readonly modelRouter: ModelRouter;
private readonly modelManager: ModelManagerService | null;
private readonly lemonade: LemonadeAdapter;
private readonly modelRouter: ModelRouter;
private readonly modelManager: ModelManagerService | null;
constructor(options?: { lemonade?: LemonadeAdapter; modelRouter?: ModelRouter; modelManager?: ModelManagerService }) {
super({ processName: PROCESS_NAME });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
this.modelManager = options?.modelManager ?? null;
}
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.type !== NODE_EXECUTE || envelope.to !== PROCESS_NAME) return;
const payload = envelope.payload as Record<string, unknown>;
const p = payload as unknown as NodeExecutePayload;
// Accept generate_text, generate, summarize; also "model-router" (planner sometimes uses service name as type)
const isGenerate =
p.type === "generate_text" || p.type === "generate" || p.type === "summarize" || p.type === "model-router";
if (!isGenerate) {
this.sendError(envelope, "UNSUPPORTED_TYPE", `Generator only handles generate_text, generate, summarize; got ${p.type}`);
return;
constructor(options?: { lemonade?: LemonadeAdapter; modelRouter?: ModelRouter; modelManager?: ModelManagerService }) {
super({ processName: PROCESS_NAME });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
this.modelManager = options?.modelManager ?? null;
}
(async () => {
let model = "unknown";
let prompt = "";
let messages: ChatMessage[] | undefined;
try {
// Check for modelClass in input, then fallback to _complexity from context, then default to "medium"
const modelClass = (p.input?.modelClass as string) ??
(p.context?._complexity as string) ??
"medium";
const tier = modelClass as ModelTier;
model = this.modelRouter.getModel(tier);
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.type !== NODE_EXECUTE || envelope.to !== PROCESS_NAME) return;
// Ensure the model is loaded before inference.
if (this.modelManager) {
await this.modelManager.ensureModelLoaded(tier);
const payload = envelope.payload as Record<string, unknown>;
const p = payload as unknown as NodeExecutePayload;
// Accept generate_text, generate, summarize; also "model-router" (planner sometimes uses service name as type)
const isGenerate =
p.type === "generate_text" || p.type === "generate" || p.type === "summarize" || p.type === "model-router";
if (!isGenerate) {
this.sendError(envelope, "UNSUPPORTED_TYPE", `Generator only handles generate_text, generate, summarize; got ${p.type}`);
return;
}
const context = (p.context ?? {}) as Record<string, unknown>;
const goal = context["_goal"] as string | undefined;
let systemPrompt: string | undefined;
if (p.type === "summarize") {
const chatHistory =
(typeof p.input?.chatHistory === "string" && p.input.chatHistory) ||
(context && typeof context.chatHistory === "string" && context.chatHistory) ||
"";
prompt = buildSummarizerPrompt(chatHistory);
systemPrompt = SUMMARIZER_SYSTEM_PROMPT;
} else if (typeof p.input?.prompt === "string") {
// When there's an explicit prompt, still include dependency outputs if available
const depOutputs = Object.entries(context)
.filter(([k]) => !k.startsWith("_"))
.map(([, v]) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
(async () => {
let model = "unknown";
let prompt = "";
let messages: ChatMessage[] | undefined;
try {
// Check for modelClass in input, then fallback to _complexity from context, then default to "medium"
const modelClass = (p.input?.modelClass as string) ??
(p.context?._complexity as string) ??
"medium";
const tier = modelClass as ModelTier;
model = this.modelRouter.getModel(tier);
// Ensure the model is loaded before inference.
if (this.modelManager) {
await this.modelManager.ensureModelLoaded(tier);
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
if (depOutputs.length > 0) {
prompt = `${p.input.prompt}\n\nContent:\n${depOutputs.join("\n\n")}`;
} else {
prompt = p.input.prompt;
}
if (typeof p.input?.system_prompt === "string") {
systemPrompt = p.input.system_prompt === "analyzer" ? ANALYZER_SYSTEM_PROMPT : p.input.system_prompt;
// If it's an analyzer prompt, use the specialized user prompt builder
if (p.input.system_prompt === "analyzer") {
prompt = buildAnalyzerUserPrompt(goal ?? p.input.prompt as string, depOutputs.join("\n\n"));
}
}
} else if (goal && (context["_criticFeedback"] != null || context["_previousDraft"] != null)) {
const feedback = context["_criticFeedback"] as string | undefined;
const previous = context["_previousDraft"] as string | undefined;
prompt = `User goal: ${goal}\n\nPrevious draft:\n${previous ?? ""}\n\nCritic feedback:\n${feedback ?? ""}\n\nProduce an improved draft that addresses the feedback. Output only the improved text.`;
} else if (goal) {
const depOutputs = Object.entries(context)
.filter(([k]) => !k.startsWith("_"))
.map(([, v]) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
const context = (p.context ?? {}) as Record<string, unknown>;
const goal = context["_goal"] as string | undefined;
let systemPrompt: string | undefined;
if (p.type === "summarize") {
const chatHistory =
(typeof p.input?.chatHistory === "string" && p.input.chatHistory) ||
(context && typeof context.chatHistory === "string" && context.chatHistory) ||
"";
prompt = buildSummarizerPrompt(chatHistory);
systemPrompt = SUMMARIZER_SYSTEM_PROMPT;
} else if (typeof p.input?.prompt === "string") {
// When there's an explicit prompt, still include dependency outputs if available
const depOutputs = Object.entries(context)
.filter(([k]) => !k.startsWith("_"))
.map(([, v]) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
if (depOutputs.length > 0) {
prompt = `${p.input.prompt}\n\nContent:\n${depOutputs.join("\n\n")}`;
} else {
prompt = p.input.prompt;
}
if (typeof p.input?.system_prompt === "string") {
systemPrompt = p.input.system_prompt === "analyzer" ? ANALYZER_SYSTEM_PROMPT : p.input.system_prompt;
// If it's an analyzer prompt, use the specialized user prompt builder
if (p.input.system_prompt === "analyzer") {
prompt = buildAnalyzerUserPrompt(goal ?? p.input.prompt as string, depOutputs.join("\n\n"));
}
}
} else if (goal && (context["_criticFeedback"] != null || context["_previousDraft"] != null)) {
const feedback = context["_criticFeedback"] as string | undefined;
const previous = context["_previousDraft"] as string | undefined;
prompt = `User goal: ${goal}\n\nPrevious draft:\n${previous ?? ""}\n\nCritic feedback:\n${feedback ?? ""}\n\nProduce an improved draft that addresses the feedback. Output only the improved text.`;
} else if (goal) {
const depOutputs = Object.entries(context)
.filter(([k]) => !k.startsWith("_"))
.map(([, v]) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
prompt = `User goal: ${goal}\n\nContext from previous steps:\n${depOutputs.join("\n\n")}\n\nProduce a direct response to the goal. Output only the response text.`;
} else {
const depOutputs = Object.values(context).map((v) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
prompt = depOutputs.join("\n\n") || "Generate a brief response.";
}
if (p.input?.messages && Array.isArray(p.input.messages)) {
messages = p.input.messages as ChatMessage[];
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
prompt = `User goal: ${goal}\n\nContext from previous steps:\n${depOutputs.join("\n\n")}\n\nProduce a direct response to the goal. Output only the response text.`;
} else {
const depOutputs = Object.values(context).map((v) => {
// Extract body from http_get responses
if (v && typeof v === "object" && "body" in v && typeof v.body === "string") {
return v.body;
}
// Extract stdout from shell tool responses
if (v && typeof v === "object" && "stdout" in v && typeof v.stdout === "string") {
const shellResult = v as { stdout: string; stderr?: string };
// Include stderr in context if present (for debugging)
if (shellResult.stderr && shellResult.stderr.trim()) {
return `${shellResult.stdout}\n\n[stderr: ${shellResult.stderr}]`;
}
return shellResult.stdout;
}
// For strings, return as-is
if (typeof v === "string") {
return v;
}
// For other objects, stringify
return JSON.stringify(v);
});
prompt = depOutputs.join("\n\n") || "Generate a brief response.";
}
if (p.input?.messages && Array.isArray(p.input.messages)) {
messages = p.input.messages as ChatMessage[];
}
if (!messages) {
messages = systemPrompt
? [{ role: "system" as const, content: systemPrompt }, { role: "user" as const, content: prompt }]
: [{ role: "user" as const, content: prompt }];
}
if (!messages) {
messages = systemPrompt
? [{ role: "system" as const, content: systemPrompt }, { role: "user" as const, content: prompt }]
: [{ role: "user" as const, content: prompt }];
}
const genResult = await this.lemonade.chat(messages, model, {
tools: p.input?.tools as any[],
const genResult = await this.lemonade.chat(messages, model, {
tools: p.input?.tools as any[],
});
const text = genResult.message.content;
const tool_calls = genResult.message.tool_calls;
this.sendResponse(envelope, {
text,
tool_calls,
usage: genResult.usage
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const isTimeout = err instanceof Error && (err.name === "AbortError" || message.includes("aborted") || message.includes("timeout"));
const errorCode = isTimeout ? "GENERATOR_TIMEOUT" : "GENERATOR_ERROR";
const details: Record<string, unknown> = {
originalError: message,
isTimeout,
model,
promptLength: prompt.length,
messageCount: messages?.length ?? 0
};
if (err instanceof Error && err.stack) {
details.stack = err.stack;
}
this.sendError(envelope, errorCode, message, details);
}
})();
}
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload,
});
}
const text = genResult.message.content;
const tool_calls = genResult.message.tool_calls;
this.sendResponse(envelope, {
text,
tool_calls,
usage: genResult.usage
private sendError(request: Envelope, code: string, message: string, details: Record<string, unknown> = {}): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload: { code, message, details },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const isTimeout = err instanceof Error && (err.name === "AbortError" || message.includes("aborted") || message.includes("timeout"));
const errorCode = isTimeout ? "GENERATOR_TIMEOUT" : "GENERATOR_ERROR";
const details: Record<string, unknown> = {
originalError: message,
isTimeout,
model,
promptLength: prompt.length,
messageCount: messages?.length ?? 0
};
if (err instanceof Error && err.stack) {
details.stack = err.stack;
}
this.sendError(envelope, errorCode, message, details);
}
})();
}
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload,
});
}
private sendError(request: Envelope, code: string, message: string, details: Record<string, unknown> = {}): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload: { code, message, details },
});
}
}
}
function main(): void {
const service = new GeneratorService();
service.start();
const service = new GeneratorService();
service.start();
}
main();

View File

@@ -8,27 +8,27 @@ import { getConfig } from "../shared/config.js";
export type ComplexityLevel = "small" | "medium" | "large";
export interface ModelRouterConfig {
small: string;
medium: string;
large: string;
small: string;
medium: string;
large: string;
}
export class ModelRouter {
private readonly config: ModelRouterConfig;
private readonly config: ModelRouterConfig;
constructor(config?: Partial<ModelRouterConfig>) {
const defaults = getConfig().modelRouter;
this.config = { ...defaults, ...config };
}
constructor(config?: Partial<ModelRouterConfig>) {
const defaults = getConfig().modelRouter;
this.config = { ...defaults, ...config };
}
/**
* Return the Ollama model name for the given complexity level.
*/
getModel(complexity: ComplexityLevel): string {
return this.config[complexity];
}
/**
* Return the Ollama model name for the given complexity level.
*/
getModel(complexity: ComplexityLevel): string {
return this.config[complexity];
}
getConfig(): ModelRouterConfig {
return { ...this.config };
}
getConfig(): ModelRouterConfig {
return { ...this.config };
}
}

View File

@@ -29,276 +29,276 @@ CREATE TABLE IF NOT EXISTS rag_documents (
`;
function embeddingToBuffer(embedding: number[]): Buffer {
const f64 = new Float64Array(embedding);
return Buffer.from(f64.buffer, f64.byteOffset, f64.byteLength);
const f64 = new Float64Array(embedding);
return Buffer.from(f64.buffer, f64.byteOffset, f64.byteLength);
}
function bufferToEmbedding(buf: Buffer): number[] {
const f64 = new Float64Array(buf.buffer, buf.byteOffset, buf.byteLength / 8);
return Array.from(f64);
const f64 = new Float64Array(buf.buffer, buf.byteOffset, buf.byteLength / 8);
return Array.from(f64);
}
function dotProduct(a: number[], b: number[]): number {
let sum = 0;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) sum += a[i]! * b[i]!;
return sum;
let sum = 0;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) sum += a[i]! * b[i]!;
return sum;
}
/** L2 distance to score: higher = more similar. */
function distanceToScore(distance: number): number {
return 1 / (1 + Math.max(0, distance));
return 1 / (1 + Math.max(0, distance));
}
/** SQLite-backed store for RAG documents. Uses sqlite-vss for KNN when available, else dot-product full scan. */
export class RAGStore {
private db: Database.Database;
private useVss: boolean;
private readonly embeddingDimensions: number;
private readonly vssTableName = "vss_rag_embedding";
private db: Database.Database;
private useVss: boolean;
private readonly embeddingDimensions: number;
private readonly vssTableName = "vss_rag_embedding";
constructor(dbPath: string, embeddingDimensions = 768) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.embeddingDimensions = embeddingDimensions;
this.db.exec(RAG_SCHEMA);
let vssLoaded = false;
try {
sqliteVss.load(this.db);
this.db.exec(
`CREATE VIRTUAL TABLE IF NOT EXISTS ${this.vssTableName} USING vss0( embedding(${this.embeddingDimensions}) )`,
);
vssLoaded = true;
} catch {
// Extension unavailable (e.g. unsupported platform); use fallback
constructor(dbPath: string, embeddingDimensions = 768) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.embeddingDimensions = embeddingDimensions;
this.db.exec(RAG_SCHEMA);
let vssLoaded = false;
try {
sqliteVss.load(this.db);
this.db.exec(
`CREATE VIRTUAL TABLE IF NOT EXISTS ${this.vssTableName} USING vss0( embedding(${this.embeddingDimensions}) )`,
);
vssLoaded = true;
} catch {
// Extension unavailable (e.g. unsupported platform); use fallback
}
this.useVss = vssLoaded;
}
this.useVss = vssLoaded;
}
close(): void {
this.db.close();
}
close(): void {
this.db.close();
}
/** Whether sqlite-vss is active (KNN search). */
isVssEnabled(): boolean {
return this.useVss;
}
/** Whether sqlite-vss is active (KNN search). */
isVssEnabled(): boolean {
return this.useVss;
}
insert(id: string, content: string, metadata: Record<string, unknown>, embedding: number[]): void {
const metaJson = JSON.stringify(metadata);
const blob = embeddingToBuffer(embedding);
const run = (): void => {
this.db
.prepare(
`INSERT INTO rag_documents (id, content, metadata, embedding) VALUES (?, ?, ?, ?)`,
)
.run(id, content, metaJson, blob);
if (this.useVss) {
const rowid = this.db.prepare("SELECT last_insert_rowid()").pluck().get() as number;
const embeddingJson = JSON.stringify(embedding);
this.db.prepare(`INSERT INTO ${this.vssTableName}(rowid, embedding) VALUES (?, ?)`).run(rowid, embeddingJson);
}
};
this.db.transaction(run)();
}
insert(id: string, content: string, metadata: Record<string, unknown>, embedding: number[]): void {
const metaJson = JSON.stringify(metadata);
const blob = embeddingToBuffer(embedding);
const run = (): void => {
this.db
.prepare(
`INSERT INTO rag_documents (id, content, metadata, embedding) VALUES (?, ?, ?, ?)`,
)
.run(id, content, metaJson, blob);
if (this.useVss) {
const rowid = this.db.prepare("SELECT last_insert_rowid()").pluck().get() as number;
const embeddingJson = JSON.stringify(embedding);
this.db.prepare(`INSERT INTO ${this.vssTableName}(rowid, embedding) VALUES (?, ?)`).run(rowid, embeddingJson);
}
};
this.db.transaction(run)();
}
search(queryEmbedding: number[], limit: number, filters?: { conversationId?: string | undefined }): Array<{ content: string; metadata: Record<string, unknown>; score: number }> {
const k = Math.max(1, Math.floor(limit));
if (this.useVss) {
const hasRows = (this.db.prepare(`SELECT 1 FROM ${this.vssTableName} LIMIT 1`).get() as unknown) != null;
if (!hasRows) return [];
const queryJson = JSON.stringify(queryEmbedding);
// Use a CTE or subquery to filter if conversationId is provided
let vssRows: Array<{ rowid: number; distance: number }>;
if (filters?.conversationId) {
vssRows = this.db
.prepare(
`SELECT t.rowid, t.distance
search(queryEmbedding: number[], limit: number, filters?: { conversationId?: string | undefined }): Array<{ content: string; metadata: Record<string, unknown>; score: number }> {
const k = Math.max(1, Math.floor(limit));
if (this.useVss) {
const hasRows = (this.db.prepare(`SELECT 1 FROM ${this.vssTableName} LIMIT 1`).get() as unknown) != null;
if (!hasRows) return [];
const queryJson = JSON.stringify(queryEmbedding);
// Use a CTE or subquery to filter if conversationId is provided
let vssRows: Array<{ rowid: number; distance: number }>;
if (filters?.conversationId) {
vssRows = this.db
.prepare(
`SELECT t.rowid, t.distance
FROM ${this.vssTableName} t
JOIN rag_documents d ON d.rowid = t.rowid
WHERE vss_search(t.embedding, ?)
AND json_extract(d.metadata, '$.conversationId') = ?
LIMIT ?`,
)
.all(queryJson, filters.conversationId, k) as Array<{ rowid: number; distance: number }>;
} else {
vssRows = this.db
.prepare(
`SELECT rowid, distance FROM ${this.vssTableName} WHERE vss_search(embedding, ?) LIMIT ?`,
)
.all(queryJson, k) as Array<{ rowid: number; distance: number }>;
}
if (vssRows.length === 0) return [];
const rowids = vssRows.map((r) => r.rowid);
const order = new Map(rowids.map((r, i) => [r, i]));
const placeholders = rowids.map(() => "?").join(",");
const docs = this.db
.prepare(
`SELECT rowid, content, metadata FROM rag_documents WHERE rowid IN (${placeholders})`,
)
.all(...rowids) as Array<{ rowid: number; content: string; metadata: string }>;
docs.sort((a, b) => (order.get(a.rowid) ?? 0) - (order.get(b.rowid) ?? 0));
const distanceByRowid = new Map(vssRows.map((r) => [r.rowid, r.distance]));
return docs.map((d) => ({
content: d.content,
metadata: (JSON.parse(d.metadata || "{}") ?? {}) as Record<string, unknown>,
score: distanceToScore(distanceByRowid.get(d.rowid) ?? 0),
}));
)
.all(queryJson, filters.conversationId, k) as Array<{ rowid: number; distance: number }>;
} else {
vssRows = this.db
.prepare(
`SELECT rowid, distance FROM ${this.vssTableName} WHERE vss_search(embedding, ?) LIMIT ?`,
)
.all(queryJson, k) as Array<{ rowid: number; distance: number }>;
}
if (vssRows.length === 0) return [];
const rowids = vssRows.map((r) => r.rowid);
const order = new Map(rowids.map((r, i) => [r, i]));
const placeholders = rowids.map(() => "?").join(",");
const docs = this.db
.prepare(
`SELECT rowid, content, metadata FROM rag_documents WHERE rowid IN (${placeholders})`,
)
.all(...rowids) as Array<{ rowid: number; content: string; metadata: string }>;
docs.sort((a, b) => (order.get(a.rowid) ?? 0) - (order.get(b.rowid) ?? 0));
const distanceByRowid = new Map(vssRows.map((r) => [r.rowid, r.distance]));
return docs.map((d) => ({
content: d.content,
metadata: (JSON.parse(d.metadata || "{}") ?? {}) as Record<string, unknown>,
score: distanceToScore(distanceByRowid.get(d.rowid) ?? 0),
}));
}
const rows = this.db.prepare(`SELECT id, content, metadata, embedding FROM rag_documents`).all() as Array<{
id: string;
content: string;
metadata: string;
embedding: Buffer;
}>;
if (rows.length === 0) return [];
let scored = rows.map((row) => {
const embedding = bufferToEmbedding(row.embedding);
const score = dotProduct(queryEmbedding, embedding);
const metadata = (JSON.parse(row.metadata || "{}") ?? {}) as Record<string, unknown>;
return { content: row.content, metadata, score };
});
if (filters?.conversationId) {
scored = scored.filter((s) => s.metadata.conversationId === filters.conversationId);
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k);
}
const rows = this.db.prepare(`SELECT id, content, metadata, embedding FROM rag_documents`).all() as Array<{
id: string;
content: string;
metadata: string;
embedding: Buffer;
}>;
if (rows.length === 0) return [];
let scored = rows.map((row) => {
const embedding = bufferToEmbedding(row.embedding);
const score = dotProduct(queryEmbedding, embedding);
const metadata = (JSON.parse(row.metadata || "{}") ?? {}) as Record<string, unknown>;
return { content: row.content, metadata, score };
});
if (filters?.conversationId) {
scored = scored.filter((s) => s.metadata.conversationId === filters.conversationId);
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, k);
}
}
interface MemorySearchPayload {
query: string;
limit?: number;
query: string;
limit?: number;
}
interface MemoryInsertPayload {
content: string;
metadata?: Record<string, unknown>;
content: string;
metadata?: Record<string, unknown>;
}
export class RAGService extends BaseProcess {
private readonly lemonade: LemonadeAdapter;
private readonly embedModel: string;
private readonly store: RAGStore;
private readonly lemonade: LemonadeAdapter;
private readonly embedModel: string;
private readonly store: RAGStore;
constructor(options?: { lemonade?: LemonadeAdapter; embedModel?: string; dbPath?: string; embeddingDimensions?: number }) {
super({ processName: PROCESS_NAME });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.embedModel = options?.embedModel ?? getConfig().rag.embedModel;
const dbPath = options?.dbPath ?? getConfig().rag.dbPath;
const embeddingDimensions = options?.embeddingDimensions ?? getConfig().rag.embeddingDimensions;
this.store = new RAGStore(dbPath, embeddingDimensions);
}
/** Embed and store a document */
async addDocument(content: string, metadata: Record<string, unknown> = {}): Promise<string> {
ConsoleLogger.debug(PROCESS_NAME, `Embedding document: ${content.substring(0, 50)}...`);
const { embedding } = await this.lemonade.embed(content, this.embedModel, { timeoutMs: 60000 });
const id = randomUUID();
this.store.insert(id, content, metadata, embedding);
ConsoleLogger.info(PROCESS_NAME, `Stored document ${id.substring(0, 8)}`);
return id;
}
/** Return relevant snippets by semantic similarity (cosine via dot product for L2-normalized vectors) */
async search(query: string, limit = 5, filters?: { conversationId?: string | undefined }): Promise<Array<{ content: string; metadata: Record<string, unknown>; score: number }>> {
ConsoleLogger.debug(PROCESS_NAME, `Searching RAG: "${query}"${filters?.conversationId ? ` (filter: ${filters.conversationId})` : ""}`);
const { embedding: queryEmbed } = await this.lemonade.embed(query, this.embedModel, { timeoutMs: 30000 });
const results = this.store.search(queryEmbed, limit, filters);
ConsoleLogger.info(PROCESS_NAME, `Found ${results.length} results for query`);
return results;
}
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.to !== PROCESS_NAME) return;
const type = envelope.type;
const payload = envelope.payload as Record<string, unknown>;
if (type === "node.execute") {
const p = payload as { type?: string; input?: Record<string, unknown> };
if (p.type !== "semantic_search") return;
const query = (p.input?.query ?? "") as string;
const limit = (typeof p.input?.limit === "number" ? p.input.limit : 5) as number;
const conversationId = p.input?.conversationId as string | undefined;
this.search(query, limit, { conversationId }).then((results) => {
this.sendResponse(envelope, { results, snippets: results.map((r) => r.content) });
}).catch((err) => {
this.sendError(envelope, "RAG_SEARCH_ERROR", err instanceof Error ? err.message : String(err));
});
return;
constructor(options?: { lemonade?: LemonadeAdapter; embedModel?: string; dbPath?: string; embeddingDimensions?: number }) {
super({ processName: PROCESS_NAME });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.embedModel = options?.embedModel ?? getConfig().rag.embedModel;
const dbPath = options?.dbPath ?? getConfig().rag.dbPath;
const embeddingDimensions = options?.embeddingDimensions ?? getConfig().rag.embeddingDimensions;
this.store = new RAGStore(dbPath, embeddingDimensions);
}
if (type === "memory.semantic.insert") {
const p = payload as unknown as MemoryInsertPayload;
const content = p.content ?? "";
const metadata = (p.metadata ?? {}) as Record<string, unknown>;
if (typeof content !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "memory.semantic.insert requires content (string)");
return;
}
this.addDocument(content, metadata).then((id) => {
this.sendResponse(envelope, { id });
}).catch((err) => {
this.sendError(envelope, "RAG_INSERT_ERROR", err instanceof Error ? err.message : String(err));
});
return;
/** Embed and store a document */
async addDocument(content: string, metadata: Record<string, unknown> = {}): Promise<string> {
ConsoleLogger.debug(PROCESS_NAME, `Embedding document: ${content.substring(0, 50)}...`);
const { embedding } = await this.lemonade.embed(content, this.embedModel, { timeoutMs: 60000 });
const id = randomUUID();
this.store.insert(id, content, metadata, embedding);
ConsoleLogger.info(PROCESS_NAME, `Stored document ${id.substring(0, 8)}`);
return id;
}
if (type === "memory.semantic.search") {
const p = payload as unknown as MemorySearchPayload & { conversationId?: string };
const query = p.query ?? "";
const limit = typeof p.limit === "number" ? p.limit : 5;
const conversationId = p.conversationId;
if (typeof query !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "memory.semantic.search requires query (string)");
return;
}
this.search(query, limit, { conversationId }).then((results) => {
this.sendResponse(envelope, { results });
}).catch((err) => {
this.sendError(envelope, "RAG_SEARCH_ERROR", err instanceof Error ? err.message : String(err));
});
return;
/** Return relevant snippets by semantic similarity (cosine via dot product for L2-normalized vectors) */
async search(query: string, limit = 5, filters?: { conversationId?: string | undefined }): Promise<Array<{ content: string; metadata: Record<string, unknown>; score: number }>> {
ConsoleLogger.debug(PROCESS_NAME, `Searching RAG: "${query}"${filters?.conversationId ? ` (filter: ${filters.conversationId})` : ""}`);
const { embedding: queryEmbed } = await this.lemonade.embed(query, this.embedModel, { timeoutMs: 30000 });
const results = this.store.search(queryEmbed, limit, filters);
ConsoleLogger.info(PROCESS_NAME, `Found ${results.length} results for query`);
return results;
}
// Catch-all: respond with error for any unrecognized message type
// This prevents the executor from hanging forever waiting for a response
this.sendError(envelope, "UNSUPPORTED_TYPE", `rag-service does not handle type="${type}"`);
}
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.to !== PROCESS_NAME) return;
const type = envelope.type;
const payload = envelope.payload as Record<string, unknown>;
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload,
});
}
if (type === "node.execute") {
const p = payload as { type?: string; input?: Record<string, unknown> };
if (p.type !== "semantic_search") return;
const query = (p.input?.query ?? "") as string;
const limit = (typeof p.input?.limit === "number" ? p.input.limit : 5) as number;
const conversationId = p.input?.conversationId as string | undefined;
private sendError(request: Envelope, code: string, message: string): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload: { code, message, details: {} },
});
}
this.search(query, limit, { conversationId }).then((results) => {
this.sendResponse(envelope, { results, snippets: results.map((r) => r.content) });
}).catch((err) => {
this.sendError(envelope, "RAG_SEARCH_ERROR", err instanceof Error ? err.message : String(err));
});
return;
}
if (type === "memory.semantic.insert") {
const p = payload as unknown as MemoryInsertPayload;
const content = p.content ?? "";
const metadata = (p.metadata ?? {}) as Record<string, unknown>;
if (typeof content !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "memory.semantic.insert requires content (string)");
return;
}
this.addDocument(content, metadata).then((id) => {
this.sendResponse(envelope, { id });
}).catch((err) => {
this.sendError(envelope, "RAG_INSERT_ERROR", err instanceof Error ? err.message : String(err));
});
return;
}
if (type === "memory.semantic.search") {
const p = payload as unknown as MemorySearchPayload & { conversationId?: string };
const query = p.query ?? "";
const limit = typeof p.limit === "number" ? p.limit : 5;
const conversationId = p.conversationId;
if (typeof query !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "memory.semantic.search requires query (string)");
return;
}
this.search(query, limit, { conversationId }).then((results) => {
this.sendResponse(envelope, { results });
}).catch((err) => {
this.sendError(envelope, "RAG_SEARCH_ERROR", err instanceof Error ? err.message : String(err));
});
return;
}
// Catch-all: respond with error for any unrecognized message type
// This prevents the executor from hanging forever waiting for a response
this.sendError(envelope, "UNSUPPORTED_TYPE", `rag-service does not handle type="${type}"`);
}
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload,
});
}
private sendError(request: Envelope, code: string, message: string): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: PROTOCOL_VERSION,
timestamp: Date.now(),
payload: { code, message, details: {} },
});
}
}
function main(): void {
const service = new RAGService();
service.start();
const service = new RAGService();
service.start();
}
main();

View File

@@ -7,320 +7,320 @@ import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
export interface LemonadeConfig {
baseUrl: string;
timeoutMs: number;
retries: number;
numCtx: number;
baseUrl: string;
timeoutMs: number;
retries: number;
numCtx: number;
}
export interface TelegramConfig {
botToken: string;
/** Comma-separated Telegram user IDs allowed to use the bot. Empty or omit = allow all. */
allowedUserIds: string;
botToken: string;
/** Comma-separated Telegram user IDs allowed to use the bot. Empty or omit = allow all. */
allowedUserIds: string;
}
export interface TaskMemoryConfig {
dbPath: string;
dbPath: string;
}
export interface LoggerConfig {
logDir: string;
logFile: string;
logDir: string;
logFile: string;
}
export interface RagConfig {
embedModel: string;
/** SQLite database path for RAG document storage. */
dbPath: string;
/** Embedding vector dimension (must match embed model, e.g. 768 for nomic-embed-text). Used for sqlite-vss. */
embeddingDimensions: number;
embedModel: string;
/** SQLite database path for RAG document storage. */
dbPath: string;
/** Embedding vector dimension (must match embed model, e.g. 768 for nomic-embed-text). Used for sqlite-vss. */
embeddingDimensions: number;
}
export interface ToolHostConfig {
/** Directory allowed for shell tool file operations. Paths outside are rejected. */
sandboxDir: string;
/** Optional colon-separated list of additional directories to add to PATH for shell commands. */
additionalPath?: string | undefined;
/** Directory allowed for shell tool file operations. Paths outside are rejected. */
sandboxDir: string;
/** Optional colon-separated list of additional directories to add to PATH for shell commands. */
additionalPath?: string | undefined;
}
export interface CronConfig {
dbPath: string;
dbPath: string;
}
export interface ModelRouterConfig {
small: string;
medium: string;
large: string;
/** Complexity level to use for initial planning phase */
plannerComplexity: string;
small: string;
medium: string;
large: string;
/** Complexity level to use for initial planning phase */
plannerComplexity: string;
}
export interface ExecutorConfig {
/** Timeout for individual node execution in milliseconds. */
nodeTimeoutMs: number;
/** Timeout for individual node execution in milliseconds. */
nodeTimeoutMs: number;
}
export interface BrowserServiceConfig {
/** Run browser in headless mode (default: true for production). */
headless: boolean;
/** Timeout for browser operations in milliseconds (default: 30000). */
timeout: number;
/** Enable stealth plugin to bypass bot detection (default: true). */
enableStealth: boolean;
/** Reuse browser context across requests (default: true). */
reuseContext: boolean;
/** Directory to store browser user data (persistent cookies, etc). */
userDataDir?: string | undefined;
/** Run browser in headless mode (default: true for production). */
headless: boolean;
/** Timeout for browser operations in milliseconds (default: 30000). */
timeout: number;
/** Enable stealth plugin to bypass bot detection (default: true). */
enableStealth: boolean;
/** Reuse browser context across requests (default: true). */
reuseContext: boolean;
/** Directory to store browser user data (persistent cookies, etc). */
userDataDir?: string | undefined;
}
export interface ModelManagerConfig {
/**
* How long to keep a small model in memory after the last request.
* Accepts an Ollama/Lemonade duration string (e.g. "5m") or a number of seconds.
*/
smallModelKeepAlive: string | number;
/**
* How long to keep a medium model in memory after the last request.
*/
mediumModelKeepAlive: string | number;
/**
* How long to keep a large model in memory after the last request.
*/
largeModelKeepAlive: string | number;
/**
* Minimal prompt sent during warmup to ensure the model is loaded.
*/
warmupPrompt: string;
/**
* How long to keep a small model in memory after the last request.
* Accepts an Ollama/Lemonade duration string (e.g. "5m") or a number of seconds.
*/
smallModelKeepAlive: string | number;
/**
* How long to keep a medium model in memory after the last request.
*/
mediumModelKeepAlive: string | number;
/**
* How long to keep a large model in memory after the last request.
*/
largeModelKeepAlive: string | number;
/**
* Minimal prompt sent during warmup to ensure the model is loaded.
*/
warmupPrompt: string;
}
export interface SkillsConfig {
/** Directory containing skills/CONFIG.md and subfolders. */
skillsDir: string;
/** Directory containing skills/CONFIG.md and subfolders. */
skillsDir: string;
}
export interface WhisperConfig {
/** Whisper model to use for transcription (e.g. "Whisper-Tiny", "Whisper-Base"). */
modelName: string;
/** Language code for transcription ("auto" for auto-detect). */
language: string;
/** Directory where Whisper model files are stored (unused when using Lemonade API). */
modelDir: string;
/** Whisper model to use for transcription (e.g. "Whisper-Tiny", "Whisper-Base"). */
modelName: string;
/** Language code for transcription ("auto" for auto-detect). */
language: string;
/** Directory where Whisper model files are stored (unused when using Lemonade API). */
modelDir: string;
}
export interface FileProcessorConfig {
/** Directory where uploaded files are temporarily stored during processing. */
uploadDir: string;
/** Maximum allowed file size in bytes (default: 52428800 = 50 MB). */
maxFileSizeBytes: number;
/** Files with text content shorter than this are inlined into the planner goal directly. */
textMaxInlineChars: number;
/** Lemonade model used for image OCR and description. */
ocrModel: string;
/** Whether image OCR/description is enabled. */
ocrEnabled: boolean;
/** Directory where uploaded files are temporarily stored during processing. */
uploadDir: string;
/** Maximum allowed file size in bytes (default: 52428800 = 50 MB). */
maxFileSizeBytes: number;
/** Files with text content shorter than this are inlined into the planner goal directly. */
textMaxInlineChars: number;
/** Lemonade model used for image OCR and description. */
ocrModel: string;
/** Whether image OCR/description is enabled. */
ocrEnabled: boolean;
}
export interface AppConfig {
lemonade: LemonadeConfig;
telegram: TelegramConfig;
taskMemory: TaskMemoryConfig;
logger: LoggerConfig;
rag: RagConfig;
toolHost: ToolHostConfig;
cron: CronConfig;
modelRouter: ModelRouterConfig;
executor: ExecutorConfig;
browserService: BrowserServiceConfig;
modelManager: ModelManagerConfig;
skills: SkillsConfig;
whisper: WhisperConfig;
fileProcessor: FileProcessorConfig;
maxConcurrentTasks: number;
lemonade: LemonadeConfig;
telegram: TelegramConfig;
taskMemory: TaskMemoryConfig;
logger: LoggerConfig;
rag: RagConfig;
toolHost: ToolHostConfig;
cron: CronConfig;
modelRouter: ModelRouterConfig;
executor: ExecutorConfig;
browserService: BrowserServiceConfig;
modelManager: ModelManagerConfig;
skills: SkillsConfig;
whisper: WhisperConfig;
fileProcessor: FileProcessorConfig;
maxConcurrentTasks: number;
}
const DEFAULT_CONFIG: AppConfig = {
lemonade: {
baseUrl: "http://127.0.0.1:8000/api/v1",
timeoutMs: 600_000, // 10 minutes default
retries: 3,
numCtx: 16384,
},
telegram: {
botToken: "",
allowedUserIds: "",
},
taskMemory: {
dbPath: "data/tasks.sqlite",
},
logger: {
logDir: "logs",
logFile: "events.log",
},
rag: {
embedModel: "text-embedding-v3", // Common OpenAI/Lemonade embed model name
dbPath: "data/rag.sqlite",
embeddingDimensions: 768,
},
toolHost: {
sandboxDir: process.cwd(),
additionalPath: "",
},
cron: {
dbPath: "data/cron.sqlite",
},
modelRouter: {
small: "qwen2.5:0.5b",
medium: "qwen2.5:1.5b",
large: "qwen2.5:7b",
plannerComplexity: "small",
},
executor: {
nodeTimeoutMs: 600_000, // 10 minutes default
},
browserService: {
headless: true,
timeout: 30_000, // 30 seconds default
enableStealth: true,
reuseContext: true,
},
modelManager: {
smallModelKeepAlive: "10m",
mediumModelKeepAlive: "30m",
largeModelKeepAlive: "60m",
warmupPrompt: "hello",
},
skills: {
skillsDir: "skills",
},
whisper: {
modelName: "Whisper-Tiny",
language: "auto",
modelDir: "data/whisper-models",
},
fileProcessor: {
uploadDir: "data/uploads",
maxFileSizeBytes: 52_428_800, // 50 MB
textMaxInlineChars: 8_000,
ocrModel: "qwen3-vl",
ocrEnabled: true,
},
maxConcurrentTasks: 1,
lemonade: {
baseUrl: "http://127.0.0.1:8000/api/v1",
timeoutMs: 600_000, // 10 minutes default
retries: 3,
numCtx: 16384,
},
telegram: {
botToken: "",
allowedUserIds: "",
},
taskMemory: {
dbPath: "data/tasks.sqlite",
},
logger: {
logDir: "logs",
logFile: "events.log",
},
rag: {
embedModel: "text-embedding-v3", // Common OpenAI/Lemonade embed model name
dbPath: "data/rag.sqlite",
embeddingDimensions: 768,
},
toolHost: {
sandboxDir: process.cwd(),
additionalPath: "",
},
cron: {
dbPath: "data/cron.sqlite",
},
modelRouter: {
small: "qwen2.5:0.5b",
medium: "qwen2.5:1.5b",
large: "qwen2.5:7b",
plannerComplexity: "small",
},
executor: {
nodeTimeoutMs: 600_000, // 10 minutes default
},
browserService: {
headless: true,
timeout: 30_000, // 30 seconds default
enableStealth: true,
reuseContext: true,
},
modelManager: {
smallModelKeepAlive: "10m",
mediumModelKeepAlive: "30m",
largeModelKeepAlive: "60m",
warmupPrompt: "hello",
},
skills: {
skillsDir: "skills",
},
whisper: {
modelName: "Whisper-Tiny",
language: "auto",
modelDir: "data/whisper-models",
},
fileProcessor: {
uploadDir: "data/uploads",
maxFileSizeBytes: 52_428_800, // 50 MB
textMaxInlineChars: 8_000,
ocrModel: "qwen3-vl",
ocrEnabled: true,
},
maxConcurrentTasks: 1,
};
function loadConfigFile(): Partial<AppConfig> {
const configPath =
process.env.CONFIG_PATH ??
join(process.cwd(), "config.json");
if (!existsSync(configPath)) return {};
try {
const raw = readFileSync(configPath, "utf-8");
return JSON.parse(raw) as Partial<AppConfig>;
} catch {
return {};
}
const configPath =
process.env.CONFIG_PATH ??
join(process.cwd(), "config.json");
if (!existsSync(configPath)) return {};
try {
const raw = readFileSync(configPath, "utf-8");
return JSON.parse(raw) as Partial<AppConfig>;
} catch {
return {};
}
}
function mergeEnv(config: AppConfig): AppConfig {
return {
lemonade: {
baseUrl: process.env.LEMONADE_BASE_URL ?? config.lemonade.baseUrl,
timeoutMs: Number(process.env.LEMONADE_TIMEOUT_MS) || config.lemonade.timeoutMs,
retries: Number(process.env.LEMONADE_RETRIES) || config.lemonade.retries,
numCtx: Number(process.env.LEMONADE_NUM_CTX) || config.lemonade.numCtx,
},
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN ?? config.telegram.botToken,
allowedUserIds: process.env.TELEGRAM_ALLOWED_USER_IDS ?? config.telegram.allowedUserIds,
},
taskMemory: {
dbPath: process.env.TASK_MEMORY_DB ?? config.taskMemory.dbPath,
},
logger: {
logDir: process.env.LOG_DIR ?? config.logger.logDir,
logFile: process.env.LOG_FILE ?? config.logger.logFile,
},
rag: {
embedModel: process.env.RAG_EMBED_MODEL ?? config.rag.embedModel,
dbPath: process.env.RAG_DB ?? config.rag.dbPath,
embeddingDimensions: Number(process.env.RAG_EMBEDDING_DIMENSIONS) || config.rag.embeddingDimensions,
},
toolHost: {
sandboxDir: process.env.TOOL_SANDBOX_DIR ?? config.toolHost.sandboxDir,
additionalPath: process.env.TOOL_ADDITIONAL_PATH ?? config.toolHost.additionalPath,
},
cron: {
dbPath: process.env.CRON_DB ?? config.cron.dbPath,
},
modelRouter: {
small: process.env.MODEL_ROUTER_SMALL ?? config.modelRouter.small,
medium: process.env.MODEL_ROUTER_MEDIUM ?? config.modelRouter.medium,
large: process.env.MODEL_ROUTER_LARGE ?? config.modelRouter.large,
plannerComplexity: process.env.MODEL_ROUTER_PLANNER_COMPLEXITY ?? config.modelRouter.plannerComplexity,
},
executor: {
nodeTimeoutMs: Number(process.env.EXECUTOR_NODE_TIMEOUT_MS) || config.executor.nodeTimeoutMs,
},
browserService: {
headless: process.env.BROWSER_SERVICE_HEADLESS === "false" ? false : config.browserService.headless,
timeout: Number(process.env.BROWSER_SERVICE_TIMEOUT) || config.browserService.timeout,
enableStealth: process.env.BROWSER_SERVICE_ENABLE_STEALTH === "false" ? false : config.browserService.enableStealth,
reuseContext: process.env.BROWSER_SERVICE_REUSE_CONTEXT === "false" ? false : config.browserService.reuseContext,
userDataDir: process.env.BROWSER_SERVICE_USER_DATA_DIR ?? config.browserService.userDataDir,
},
modelManager: {
smallModelKeepAlive: process.env.MODEL_MANAGER_SMALL_KEEP_ALIVE ?? config.modelManager.smallModelKeepAlive,
mediumModelKeepAlive: process.env.MODEL_MANAGER_MEDIUM_KEEP_ALIVE ?? config.modelManager.mediumModelKeepAlive,
largeModelKeepAlive: process.env.MODEL_MANAGER_LARGE_KEEP_ALIVE ?? config.modelManager.largeModelKeepAlive,
warmupPrompt: process.env.MODEL_MANAGER_WARMUP_PROMPT ?? config.modelManager.warmupPrompt,
},
skills: {
skillsDir: process.env.SKILLS_DIR ?? config.skills.skillsDir,
},
whisper: {
modelName: process.env.WHISPER_MODEL_NAME ?? config.whisper.modelName,
language: process.env.WHISPER_LANGUAGE ?? config.whisper.language,
modelDir: process.env.WHISPER_MODEL_DIR ?? config.whisper.modelDir,
},
fileProcessor: {
uploadDir: process.env.FILE_PROCESSOR_UPLOAD_DIR ?? config.fileProcessor.uploadDir,
maxFileSizeBytes: Number(process.env.FILE_PROCESSOR_MAX_FILE_SIZE_BYTES) || config.fileProcessor.maxFileSizeBytes,
textMaxInlineChars: Number(process.env.FILE_PROCESSOR_TEXT_MAX_INLINE_CHARS) || config.fileProcessor.textMaxInlineChars,
ocrModel: process.env.FILE_PROCESSOR_OCR_MODEL ?? config.fileProcessor.ocrModel,
ocrEnabled: process.env.FILE_PROCESSOR_OCR_ENABLED === "false" ? false : config.fileProcessor.ocrEnabled,
},
maxConcurrentTasks: Number(process.env.MAX_CONCURRENT_TASKS) || config.maxConcurrentTasks,
};
return {
lemonade: {
baseUrl: process.env.LEMONADE_BASE_URL ?? config.lemonade.baseUrl,
timeoutMs: Number(process.env.LEMONADE_TIMEOUT_MS) || config.lemonade.timeoutMs,
retries: Number(process.env.LEMONADE_RETRIES) || config.lemonade.retries,
numCtx: Number(process.env.LEMONADE_NUM_CTX) || config.lemonade.numCtx,
},
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN ?? config.telegram.botToken,
allowedUserIds: process.env.TELEGRAM_ALLOWED_USER_IDS ?? config.telegram.allowedUserIds,
},
taskMemory: {
dbPath: process.env.TASK_MEMORY_DB ?? config.taskMemory.dbPath,
},
logger: {
logDir: process.env.LOG_DIR ?? config.logger.logDir,
logFile: process.env.LOG_FILE ?? config.logger.logFile,
},
rag: {
embedModel: process.env.RAG_EMBED_MODEL ?? config.rag.embedModel,
dbPath: process.env.RAG_DB ?? config.rag.dbPath,
embeddingDimensions: Number(process.env.RAG_EMBEDDING_DIMENSIONS) || config.rag.embeddingDimensions,
},
toolHost: {
sandboxDir: process.env.TOOL_SANDBOX_DIR ?? config.toolHost.sandboxDir,
additionalPath: process.env.TOOL_ADDITIONAL_PATH ?? config.toolHost.additionalPath,
},
cron: {
dbPath: process.env.CRON_DB ?? config.cron.dbPath,
},
modelRouter: {
small: process.env.MODEL_ROUTER_SMALL ?? config.modelRouter.small,
medium: process.env.MODEL_ROUTER_MEDIUM ?? config.modelRouter.medium,
large: process.env.MODEL_ROUTER_LARGE ?? config.modelRouter.large,
plannerComplexity: process.env.MODEL_ROUTER_PLANNER_COMPLEXITY ?? config.modelRouter.plannerComplexity,
},
executor: {
nodeTimeoutMs: Number(process.env.EXECUTOR_NODE_TIMEOUT_MS) || config.executor.nodeTimeoutMs,
},
browserService: {
headless: process.env.BROWSER_SERVICE_HEADLESS === "false" ? false : config.browserService.headless,
timeout: Number(process.env.BROWSER_SERVICE_TIMEOUT) || config.browserService.timeout,
enableStealth: process.env.BROWSER_SERVICE_ENABLE_STEALTH === "false" ? false : config.browserService.enableStealth,
reuseContext: process.env.BROWSER_SERVICE_REUSE_CONTEXT === "false" ? false : config.browserService.reuseContext,
userDataDir: process.env.BROWSER_SERVICE_USER_DATA_DIR ?? config.browserService.userDataDir,
},
modelManager: {
smallModelKeepAlive: process.env.MODEL_MANAGER_SMALL_KEEP_ALIVE ?? config.modelManager.smallModelKeepAlive,
mediumModelKeepAlive: process.env.MODEL_MANAGER_MEDIUM_KEEP_ALIVE ?? config.modelManager.mediumModelKeepAlive,
largeModelKeepAlive: process.env.MODEL_MANAGER_LARGE_KEEP_ALIVE ?? config.modelManager.largeModelKeepAlive,
warmupPrompt: process.env.MODEL_MANAGER_WARMUP_PROMPT ?? config.modelManager.warmupPrompt,
},
skills: {
skillsDir: process.env.SKILLS_DIR ?? config.skills.skillsDir,
},
whisper: {
modelName: process.env.WHISPER_MODEL_NAME ?? config.whisper.modelName,
language: process.env.WHISPER_LANGUAGE ?? config.whisper.language,
modelDir: process.env.WHISPER_MODEL_DIR ?? config.whisper.modelDir,
},
fileProcessor: {
uploadDir: process.env.FILE_PROCESSOR_UPLOAD_DIR ?? config.fileProcessor.uploadDir,
maxFileSizeBytes: Number(process.env.FILE_PROCESSOR_MAX_FILE_SIZE_BYTES) || config.fileProcessor.maxFileSizeBytes,
textMaxInlineChars: Number(process.env.FILE_PROCESSOR_TEXT_MAX_INLINE_CHARS) || config.fileProcessor.textMaxInlineChars,
ocrModel: process.env.FILE_PROCESSOR_OCR_MODEL ?? config.fileProcessor.ocrModel,
ocrEnabled: process.env.FILE_PROCESSOR_OCR_ENABLED === "false" ? false : config.fileProcessor.ocrEnabled,
},
maxConcurrentTasks: Number(process.env.MAX_CONCURRENT_TASKS) || config.maxConcurrentTasks,
};
}
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
const out = { ...base };
for (const key of Object.keys(override) as (keyof T)[]) {
const v = override[key];
if (v === undefined) continue;
if (typeof v === "object" && v !== null && !Array.isArray(v) && typeof base[key] === "object" && base[key] !== null) {
(out as Record<string, unknown>)[key as string] = deepMerge(
base[key] as object,
v as Partial<typeof base[typeof key]>,
);
} else {
(out as Record<string, unknown>)[key as string] = v;
const out = { ...base };
for (const key of Object.keys(override) as (keyof T)[]) {
const v = override[key];
if (v === undefined) continue;
if (typeof v === "object" && v !== null && !Array.isArray(v) && typeof base[key] === "object" && base[key] !== null) {
(out as Record<string, unknown>)[key as string] = deepMerge(
base[key] as object,
v as Partial<typeof base[typeof key]>,
);
} else {
(out as Record<string, unknown>)[key as string] = v;
}
}
}
return out;
return out;
}
let cached: AppConfig | null = null;
/** Get app config. Config file is merged over defaults, then env overrides. */
export function getConfig(): AppConfig {
if (cached) return cached;
const fileConfig = loadConfigFile();
const merged = deepMerge(DEFAULT_CONFIG, fileConfig);
cached = mergeEnv(merged);
return cached;
if (cached) return cached;
const fileConfig = loadConfigFile();
const merged = deepMerge(DEFAULT_CONFIG, fileConfig);
cached = mergeEnv(merged);
return cached;
}
/** Reset cached config (e.g. for tests). */
export function resetConfig(): void {
cached = null;
cached = null;
}