omniharness-cli 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,7 @@ The harness treats the **current working directory** as the workspace.
35
35
  |---|---|
36
36
  | `OMNIROUTE_URL` | OmniRoute endpoint (default `http://127.0.0.1:20128`, the HTTP API port) |
37
37
  | `OMNIROUTE_API_KEY` | OmniRoute API key — `Authorization: Bearer <key>` on every request |
38
+ | `OMNIROUTE_MGMT_TOKEN` | OmniRoute management token (`manage` scope) — when set, OmniRoute's MCP tools are discovered and exposed to the agent |
38
39
 
39
40
  If `OMNIROUTE_API_KEY` is unset, the harness asks you to paste the key on
40
41
  interactive launch and holds it **in memory only** — it is never written to
@@ -1,5 +1,6 @@
1
- import { OmniRouteClient } from '../config/omniRoute.js';
2
- import type { AgentMode, HarnessState } from '../types/index.js';
1
+ import { type AttachmentInput } from '../attachments.js';
2
+ import { OmniRouteClient, type CompressionInfo, type McpToolDescriptor } from '../config/omniRoute.js';
3
+ import type { AgentMode, HarnessState, TodoItem } from '../types/index.js';
3
4
  import { type SystemTools } from '../tools/systemTools.js';
4
5
  import { type Skill } from '../skills.js';
5
6
  export interface MastraEngineConfig {
@@ -8,6 +9,8 @@ export interface MastraEngineConfig {
8
9
  mode?: AgentMode;
9
10
  endpoint?: string;
10
11
  apiKey?: string;
12
+ /** OmniRoute management token: when set, OmniRoute MCP tools are discovered and exposed to the agent. */
13
+ mgmtToken?: string;
11
14
  shellAllowed?: boolean;
12
15
  }
13
16
  export type HarnessEvent = {
@@ -34,9 +37,19 @@ export type HarnessEvent = {
34
37
  } | {
35
38
  type: 'text';
36
39
  content: string;
40
+ model?: string;
41
+ compression?: CompressionInfo;
37
42
  } | {
38
43
  type: 'preview';
39
44
  url: string;
45
+ } | {
46
+ type: 'attach';
47
+ name: string;
48
+ kind: AttachmentInput['kind'];
49
+ size: number;
50
+ } | {
51
+ type: 'todos';
52
+ todos: readonly TodoItem[];
40
53
  };
41
54
  export interface ApprovalAction {
42
55
  tool: string;
@@ -47,13 +60,19 @@ export interface MastraEngine {
47
60
  readonly tools: SystemTools;
48
61
  readonly state: HarnessState;
49
62
  readonly skills: readonly Skill[];
63
+ readonly mcpTools: readonly McpToolDescriptor[];
50
64
  subscribe(listener: (event: HarnessEvent) => void): () => void;
51
65
  selectModel(model: string): Promise<void>;
66
+ attach(paths: readonly string[]): Promise<readonly AttachmentInput[]>;
52
67
  setApprovalHandler(handler: (action: ApprovalAction) => Promise<boolean>): void;
53
68
  run(prompt: string, signal?: AbortSignal): Promise<{
54
69
  content: string;
55
70
  model: string;
56
71
  }>;
72
+ /** Abort the in-flight run; the turn ends with a 'cancelled' status. */
73
+ cancel(): void;
74
+ /** Drop transcript, task queue and persisted session; starts a fresh chat. */
75
+ clearHistory(): Promise<void>;
57
76
  stop(): void;
58
77
  }
59
78
  export declare function createMastraEngine(config: MastraEngineConfig): Promise<MastraEngine>;
@@ -1,8 +1,14 @@
1
1
  import { exec, spawn } from 'node:child_process';
2
+ import { appendFile, mkdir, readdir, readFile, stat } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { attachmentBlock, kindFromName } from '../attachments.js';
2
5
  import { OmniRouteClient } from '../config/omniRoute.js';
3
6
  import { saveActiveCombo } from '../config/settings.js';
4
7
  import { createSystemTools } from '../tools/systemTools.js';
5
8
  import { loadSkills, renderSkillCommand, skillSchema } from '../skills.js';
9
+ import { chunkText, cosineSimilarity } from '../search.js';
10
+ import { loadSemanticIndex, saveSemanticIndex } from '../semanticStore.js';
11
+ import { loadSession, saveSession, clearSession } from '../sessionStore.js';
6
12
  const VERIFY_RULES = '\n\nWORK LOGIC — you MUST follow this discipline when implementing:\n'
7
13
  + '1. Read files with read_file before editing them. Never guess at contents.\n'
8
14
  + '2. Make the smallest correct change with write_file, then run the relevant checks with run_command '
@@ -18,27 +24,99 @@ const MODE_PROMPT = {
18
24
  plan: 'You are in PLAN mode: investigate the workspace, name risks and steps, and produce a concrete plan. Do not edit files or run commands. Use read_file, index_workspace, and git_diff.',
19
25
  build: 'You are in BUILD mode: implement the request with minimal, correct changes. The full work discipline below is MANDATORY.',
20
26
  research: 'You are in RESEARCH mode: investigate and answer with evidence from the workspace. Use read_file, index_workspace, and git_diff. Do not modify files or run commands.',
27
+ crazy: 'You are in CRAZY MODE: a fully autonomous agent (OpenClaw/Hermes style) that works continuously without asking for permission — every tool call is auto-approved. Decide your own next steps, keep the visible task queue current with update_todo, persist important facts with write_memory, and keep working until the task is genuinely complete. Verify your own work and iterate.',
21
28
  };
22
- const SYSTEM_FRAME = (endpoint, root, mode, skillNames) => 'You are OmniHarness, an autonomous developer agent running inside the user\'s terminal (OmniHarness CLI, powered by the OmniRoute gateway at '
29
+ const MEMORY_FILE = 'memory.md';
30
+ const SYSTEM_FRAME = (endpoint, root, mode, skillNames, memory) => 'You are OmniHarness, an autonomous developer agent running inside the user\'s terminal (OmniHarness CLI, powered by the OmniRoute gateway at '
23
31
  + `${endpoint}). Workspace: ${root}. Act carefully and concretely; use the provided tools rather than guessing at file contents. `
24
32
  + MODE_PROMPT[mode]
25
33
  + (mode === 'build' ? VERIFY_RULES : '')
34
+ + (mode === 'crazy' && memory !== '' ? `\n\nPERSISTENT MEMORY (from previous sessions):\n${memory}` : '')
26
35
  + (skillNames.length > 0 ? `\nCustom skills available: ${skillNames.map((name) => `\`${name}\``).join(', ')}.` : '');
27
- const MAX_TURNS = 24;
36
+ const MAX_TURNS = { plan: 12, build: 24, research: 12, crazy: 120 };
28
37
  const MAX_OUTPUT = 32_000;
29
38
  const RISKY_TOOLS = new Set(['write_file', 'run_command', 'start_preview']);
30
39
  export async function createMastraEngine(config) {
31
- const client = new OmniRouteClient({ endpoint: config.endpoint, apiKey: config.apiKey });
40
+ const client = new OmniRouteClient({ endpoint: config.endpoint, apiKey: config.apiKey, mgmtToken: config.mgmtToken });
32
41
  const tools = createSystemTools(config.workspaceRoot, config.shellAllowed ?? false);
33
42
  const activeModel = config.model ?? 'auto/best-coding';
34
43
  const listeners = new Set();
35
44
  let preview = null;
36
45
  let previewChild = null;
37
46
  let approvalHandler = null;
47
+ let pendingAttachments = [];
48
+ const semanticCache = await loadSemanticIndex(config.workspaceRoot);
49
+ let activeRunController = null;
38
50
  const state = {
39
51
  taskStatus: 'idle', prompt: '', mode: config.mode ?? 'build', activeModel,
40
52
  workspace: { root: config.workspaceRoot, indexedAt: null, files: [], contextLocked: false },
41
- metrics: client.snapshotMetrics(), messages: [], preview: null,
53
+ metrics: client.snapshotMetrics(), messages: [], preview: null, taskQueue: [],
54
+ };
55
+ // Resume: hydrate the transcript and task queue from the last persisted session.
56
+ const restored = await loadSession(config.workspaceRoot);
57
+ if (restored != null && restored.messages.length > 0) {
58
+ state.messages = restored.messages;
59
+ state.taskQueue = restored.taskQueue;
60
+ }
61
+ const memoryPath = join(state.workspace.root, '.omniharness', MEMORY_FILE);
62
+ let memory = '';
63
+ try {
64
+ memory = (await readFile(memoryPath, 'utf8')).trim();
65
+ }
66
+ catch { /* no memory yet */ }
67
+ const emitTodos = () => {
68
+ const snapshot = { todos: [...state.taskQueue], updatedAt: new Date().toISOString() };
69
+ state.lastTodoUpdate = snapshot;
70
+ emit({ type: 'todos', todos: snapshot.todos });
71
+ };
72
+ const applyTodo = (action) => {
73
+ const queue = [...state.taskQueue];
74
+ switch (action.action) {
75
+ case 'add': {
76
+ const id = `t${Date.now().toString(36)}${queue.length.toString(36)}`;
77
+ queue.push({ id, title: action.title, status: 'pending' });
78
+ state.taskQueue = queue;
79
+ emitTodos();
80
+ return `todo added: ${action.title}`;
81
+ }
82
+ case 'update': {
83
+ const item = queue.find((entry) => entry.id === action.id);
84
+ if (!item)
85
+ return `error: no todo with id ${action.id}`;
86
+ item.title = action.title ?? item.title;
87
+ state.taskQueue = queue;
88
+ emitTodos();
89
+ return `todo updated: ${item.title}`;
90
+ }
91
+ case 'start': {
92
+ const target = (action.id ? queue.find((entry) => entry.id === action.id) : undefined) ?? queue.find((entry) => entry.status === 'pending');
93
+ if (!target)
94
+ return 'error: no pending todo to start';
95
+ for (const entry of queue)
96
+ entry.status = entry.id === target.id ? 'active' : entry.status === 'active' ? 'pending' : entry.status;
97
+ state.taskQueue = queue;
98
+ emitTodos();
99
+ return `started: ${target.title}`;
100
+ }
101
+ case 'complete': {
102
+ const target = (action.id ? queue.find((entry) => entry.id === action.id) : undefined) ?? queue.find((entry) => entry.status !== 'done');
103
+ if (!target)
104
+ return 'error: no todo to complete';
105
+ target.status = 'done';
106
+ state.taskQueue = queue;
107
+ emitTodos();
108
+ return `completed: ${target.title}`;
109
+ }
110
+ case 'remove': {
111
+ const before = queue.length;
112
+ state.taskQueue = queue.filter((entry) => entry.id !== action.id);
113
+ if (state.taskQueue.length === before)
114
+ return `error: no todo with id ${action.id}`;
115
+ emitTodos();
116
+ return `todo removed`;
117
+ }
118
+ default: return 'error: unknown todo action';
119
+ }
42
120
  };
43
121
  const emit = (event) => { for (const listener of listeners)
44
122
  listener(event); };
@@ -73,6 +151,43 @@ export async function createMastraEngine(config) {
73
151
  name: 'git_diff', description: tools.gitDiff.description, highRisk: false, parameters: { type: 'object', properties: {} },
74
152
  execute: async (_, signal) => { const r = await tools.gitDiff.execute(undefined, signal); return r === '' ? '(no diff)' : r; },
75
153
  },
154
+ semantic_search: {
155
+ name: 'semantic_search', description: 'Embed the workspace and search it by meaning, returning the most relevant files with matching snippets. Use instead of guessing which file holds a concept.', highRisk: false, parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'integer' }, refresh: { type: 'boolean' } }, required: ['query'] },
156
+ execute: async (input, signal) => {
157
+ const query = String(input.query ?? '').trim();
158
+ if (query === '')
159
+ return 'error: query is required';
160
+ if (input.refresh === true)
161
+ semanticCache.clear();
162
+ const limit = typeof input.limit === 'number' && input.limit > 0 ? Math.min(input.limit, 10) : 5;
163
+ const chunks = await indexWorkspaceSemantics(client, semanticCache, state.workspace.root);
164
+ void saveSemanticIndex(state.workspace.root, semanticCache).catch(() => { });
165
+ if (chunks.length === 0)
166
+ return 'no indexable text files in the workspace';
167
+ const [queryVector] = await client.embed([query], undefined, signal);
168
+ const ranked = chunks
169
+ .map((chunk) => ({ chunk, score: cosineSimilarity(queryVector, chunk.embedding) }))
170
+ .sort((a, b) => b.score - a.score)
171
+ .slice(0, limit);
172
+ return ranked.map(({ chunk, score }) => `${chunk.path} (${score.toFixed(3)})\n ${chunk.text.slice(0, 200)}`).join('\n');
173
+ },
174
+ },
175
+ update_todo: {
176
+ name: 'update_todo', description: 'Maintain the visible task queue the user watches: add a step, start it, complete it, or remove it. Keep steps small and current as you work.', highRisk: false, parameters: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'update', 'start', 'complete', 'remove'] }, title: { type: 'string' }, id: { type: 'string' } }, required: ['action'] },
177
+ execute: async (input) => applyTodo(input),
178
+ },
179
+ write_memory: {
180
+ name: 'write_memory', description: 'Persist a fact to long-term memory so future sessions remember it. Use for decisions, learned constraints, and project state worth keeping.', highRisk: false, parameters: { type: 'object', properties: { fact: { type: 'string' } }, required: ['fact'] },
181
+ execute: async (input) => {
182
+ const fact = String(input.fact ?? '').trim();
183
+ if (fact === '')
184
+ return 'error: fact is required';
185
+ await mkdir(join(state.workspace.root, '.omniharness'), { recursive: true });
186
+ await appendFile(memoryPath, `- ${new Date().toISOString()}: ${fact}\n`, 'utf8');
187
+ memory = `${memory}${memory === '' ? '' : '\n'}- ${fact}`;
188
+ return 'memory saved';
189
+ },
190
+ },
76
191
  start_preview: {
77
192
  name: 'start_preview', description: 'Start a local preview server for the workspace and report its URL. Provide the command to run.', highRisk: true, parameters: { type: 'object', properties: { command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, port: { type: 'integer' } }, required: ['command'] },
78
193
  execute: async (input) => {
@@ -121,6 +236,27 @@ export async function createMastraEngine(config) {
121
236
  },
122
237
  };
123
238
  }
239
+ // When an OmniRoute management token is configured, discover the gateway's MCP
240
+ // tools and expose them to the agent. Best-effort: a discovery failure (gateway
241
+ // down, disabled MCP transport, wrong scopes) simply leaves the built-in tools.
242
+ let mcpTools = [];
243
+ if (client.hasMcpToken) {
244
+ try {
245
+ mcpTools = await client.listMcpTools();
246
+ for (const descriptor of mcpTools) {
247
+ if (systemTools[descriptor.name])
248
+ continue; // never shadow a built-in tool
249
+ systemTools[descriptor.name] = {
250
+ name: descriptor.name, description: descriptor.description ?? '', highRisk: false,
251
+ parameters: descriptor.inputSchema ?? { type: 'object', properties: {} },
252
+ execute: (input, signal) => client.callMcpTool(descriptor.name, input, signal),
253
+ };
254
+ }
255
+ }
256
+ catch {
257
+ mcpTools = [];
258
+ }
259
+ }
124
260
  const toolSchemas = Object.values(systemTools).map((entry) => ({
125
261
  type: 'function', function: { name: entry.name, description: entry.description, parameters: entry.parameters },
126
262
  }));
@@ -141,7 +277,7 @@ export async function createMastraEngine(config) {
141
277
  catch {
142
278
  parsed = {};
143
279
  }
144
- if (approvalHandler && registered.highRisk) {
280
+ if (state.mode !== 'crazy' && approvalHandler && registered.highRisk) {
145
281
  emit({ type: 'approval_requested', tool: call.function.name, input: parsed });
146
282
  const approved = await approvalHandler({ tool: call.function.name, input: parsed });
147
283
  if (!approved)
@@ -155,7 +291,7 @@ export async function createMastraEngine(config) {
155
291
  }
156
292
  }
157
293
  return {
158
- client, tools, state, skills,
294
+ client, tools, state, skills, mcpTools,
159
295
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
160
296
  setApprovalHandler(handler) { approvalHandler = handler; },
161
297
  async selectModel(model) {
@@ -165,119 +301,217 @@ export async function createMastraEngine(config) {
165
301
  }
166
302
  catch { /* persistence is best-effort */ }
167
303
  },
304
+ async attach(paths) {
305
+ const loaded = [];
306
+ const failures = [];
307
+ for (const raw of paths) {
308
+ const full = resolve(state.workspace.root, raw);
309
+ try {
310
+ const info = await stat(full);
311
+ if (!info.isFile()) {
312
+ failures.push(`${raw}: not a file`);
313
+ continue;
314
+ }
315
+ const kind = kindFromName(raw);
316
+ const base = { name: raw, size: info.size, kind };
317
+ const attachment = base;
318
+ if (kind === 'image' && info.size <= 10 * 1024 * 1024) {
319
+ const bytes = await readFile(full);
320
+ attachment.dataUrl = `data:image/${mimeFromName(raw)};base64,${bytes.toString('base64')}`;
321
+ }
322
+ loaded.push(attachment);
323
+ emit({ type: 'attach', name: raw, kind, size: info.size });
324
+ }
325
+ catch (reason) {
326
+ failures.push(`${raw}: ${reason instanceof Error ? reason.message : String(reason)}`);
327
+ }
328
+ }
329
+ if (failures.length > 0)
330
+ throw new Error(`attach failed: ${failures.join('; ')}`);
331
+ pendingAttachments = loaded;
332
+ return loaded.map(({ dataUrl: _dataUrl, ...rest }) => rest);
333
+ },
168
334
  stop() { void stopPreviewChild(); listeners.clear(); },
335
+ cancel() { activeRunController?.abort(); },
336
+ async clearHistory() {
337
+ activeRunController?.abort();
338
+ state.messages = [];
339
+ state.taskQueue = [];
340
+ state.prompt = '';
341
+ try {
342
+ await clearSession(state.workspace.root);
343
+ }
344
+ catch { /* best-effort */ }
345
+ },
169
346
  async run(prompt, signal) {
347
+ // Abort any still-in-flight run (normally impossible: the UI submits serially)
348
+ // and register this run's controller so cancel() can abort it.
349
+ activeRunController?.abort();
350
+ const controller = new AbortController();
351
+ activeRunController = controller;
352
+ if (signal)
353
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
354
+ const runSignal = controller.signal;
170
355
  state.prompt = prompt;
171
356
  state.taskStatus = 'running';
172
357
  const userMessage = { role: 'user', content: prompt, createdAt: new Date().toISOString() };
173
358
  state.messages = [...state.messages, userMessage];
174
359
  const wire = [
175
- { role: 'system', content: SYSTEM_FRAME(client.endpoint, state.workspace.root, state.mode, skills.map((skill) => skill.name)) },
360
+ { role: 'system', content: SYSTEM_FRAME(client.endpoint, state.workspace.root, state.mode, skills.map((skill) => skill.name), memory) },
176
361
  ...state.messages.map(asWireMessage),
177
362
  ];
363
+ if (pendingAttachments.length > 0) {
364
+ const note = attachmentBlock(pendingAttachments);
365
+ const images = pendingAttachments
366
+ .filter((a) => a.dataUrl !== undefined)
367
+ .map((a) => ({ type: 'image_url', image_url: { url: a.dataUrl } }));
368
+ wire[wire.length - 1] = images.length > 0
369
+ ? { role: 'user', content: [{ type: 'text', text: `${note}${prompt}` }, ...images] }
370
+ : { role: 'user', content: `${note}${prompt}` };
371
+ pendingAttachments = [];
372
+ }
178
373
  const results = [];
179
374
  let turn = 0;
180
375
  let model = activeModel;
181
376
  let content = '';
182
377
  let reasoning = '';
183
378
  let toolCalls = [];
184
- const finishRound = async () => {
185
- content = '';
186
- reasoning = '';
187
- toolCalls = [];
188
- const result = await client.chatStream(state.activeModel, wire, {
189
- signal, tools: toolSchemas,
190
- onDelta: (delta) => {
191
- if (delta.type === 'reasoning') {
192
- reasoning += delta.delta;
193
- emit({ type: 'thinking_delta', delta: delta.delta });
194
- }
195
- else if (delta.type === 'text') {
196
- content += delta.delta;
197
- emit({ type: 'text_delta', delta: delta.delta });
198
- }
199
- else if (delta.type === 'tool_call') {
200
- if (!toolCalls.some((call) => call.id === delta.call.id)) {
201
- toolCalls.push(delta.call);
379
+ let compression;
380
+ try {
381
+ return await executeRound();
382
+ }
383
+ catch (reason) {
384
+ // An AbortError during streaming or a tool call is a user cancel, not a
385
+ // failure: report it as a cancelled turn rather than an error.
386
+ if (runSignal.aborted) {
387
+ state.taskStatus = 'cancelled';
388
+ emit({ type: 'text', content: '(cancelled)', model: activeModel });
389
+ return { content: '(cancelled)', model: activeModel };
390
+ }
391
+ throw reason;
392
+ }
393
+ async function executeRound() {
394
+ const finishRound = async () => {
395
+ content = '';
396
+ reasoning = '';
397
+ toolCalls = [];
398
+ const result = await client.chatStream(state.activeModel, wire, {
399
+ signal: runSignal, tools: toolSchemas,
400
+ onDelta: (delta) => {
401
+ if (delta.type === 'reasoning') {
402
+ reasoning += delta.delta;
403
+ emit({ type: 'thinking_delta', delta: delta.delta });
404
+ }
405
+ else if (delta.type === 'text') {
406
+ content += delta.delta;
407
+ emit({ type: 'text_delta', delta: delta.delta });
202
408
  }
203
- else {
204
- toolCalls = toolCalls.map((call) => call.id === delta.call.id ? delta.call : call);
409
+ else if (delta.type === 'tool_call') {
410
+ if (!toolCalls.some((call) => call.id === delta.call.id)) {
411
+ toolCalls.push(delta.call);
412
+ }
413
+ else {
414
+ toolCalls = toolCalls.map((call) => call.id === delta.call.id ? delta.call : call);
415
+ }
205
416
  }
417
+ },
418
+ });
419
+ if (result.model)
420
+ model = result.model;
421
+ if (result.compression)
422
+ compression = result.compression;
423
+ };
424
+ await finishRound();
425
+ while (toolCalls.length > 0) {
426
+ if (runSignal.aborted) {
427
+ state.taskStatus = 'cancelled';
428
+ emit({ type: 'text', content: '(cancelled)', model: activeModel });
429
+ return { content: '(cancelled)', model: activeModel };
430
+ }
431
+ if (reasoning) {
432
+ emit({ type: 'thinking', text: reasoning });
433
+ state.messages = [...state.messages, { role: 'thought', content: reasoning, createdAt: new Date().toISOString() }];
434
+ }
435
+ wire.push({ role: 'assistant', content, tool_calls: toolCalls });
436
+ let executedAny = false;
437
+ for (const call of toolCalls) {
438
+ if (runSignal.aborted)
439
+ break;
440
+ turn += 1;
441
+ if (turn > MAX_TURNS[state.mode]) {
442
+ const error = `too many tool turns (limit ${MAX_TURNS[state.mode]})`;
443
+ state.messages = [...state.messages, { role: 'error', content: error, createdAt: new Date().toISOString() }];
444
+ state.taskStatus = 'failed';
445
+ return { content: error + '; latest text: ' + content, model: activeModel };
206
446
  }
207
- },
208
- });
209
- if (result.model)
210
- model = result.model;
211
- };
212
- await finishRound();
213
- while (toolCalls.length > 0) {
447
+ emit({ type: 'tool_start', tool: call.function.name, input: undefined });
448
+ const output = await runTool(call, runSignal);
449
+ const summary = output.split('\n')[0] ?? '';
450
+ emit({ type: 'tool_result', tool: call.function.name, summary });
451
+ results.push({ call, output });
452
+ wire.push({ role: 'tool', tool_call_id: call.id, content: truncate(output) });
453
+ executedAny = true;
454
+ }
455
+ if (!executedAny)
456
+ break;
457
+ content = '';
458
+ reasoning = '';
459
+ toolCalls = [];
460
+ const next = await client.chatStream(state.activeModel, wire, {
461
+ signal: runSignal, tools: toolSchemas,
462
+ onDelta: (delta) => {
463
+ if (delta.type === 'reasoning') {
464
+ reasoning += delta.delta;
465
+ emit({ type: 'thinking_delta', delta: delta.delta });
466
+ }
467
+ else if (delta.type === 'text') {
468
+ content += delta.delta;
469
+ emit({ type: 'text_delta', delta: delta.delta });
470
+ }
471
+ else if (delta.type === 'tool_call') {
472
+ if (!toolCalls.some((call) => call.id === delta.call.id)) {
473
+ toolCalls.push(delta.call);
474
+ }
475
+ else {
476
+ toolCalls = toolCalls.map((call) => call.id === delta.call.id ? delta.call : call);
477
+ }
478
+ }
479
+ },
480
+ });
481
+ if (next.model)
482
+ model = next.model;
483
+ if (next.compression)
484
+ compression = next.compression;
485
+ }
486
+ state.taskStatus = 'completed';
487
+ state.metrics = client.snapshotMetrics();
488
+ if (state.mode === 'crazy') {
489
+ const summary = content.split('\n')[0]?.slice(0, 160) ?? '';
490
+ try {
491
+ await mkdir(join(state.workspace.root, '.omniharness'), { recursive: true });
492
+ await appendFile(memoryPath, `- ${new Date().toISOString()}: ran "${prompt.slice(0, 80)}" → ${summary}\n`, 'utf8');
493
+ }
494
+ catch { /* memory persistence is best-effort */ }
495
+ }
496
+ const answer = content;
214
497
  if (reasoning) {
215
498
  emit({ type: 'thinking', text: reasoning });
216
499
  state.messages = [...state.messages, { role: 'thought', content: reasoning, createdAt: new Date().toISOString() }];
217
500
  }
218
- wire.push({ role: 'assistant', content, tool_calls: toolCalls });
219
- let executedAny = false;
220
- for (const call of toolCalls) {
221
- if (signal?.aborted)
222
- break;
223
- turn += 1;
224
- if (turn > MAX_TURNS) {
225
- const error = `too many tool turns (limit ${MAX_TURNS})`;
226
- state.messages = [...state.messages, { role: 'error', content: error, createdAt: new Date().toISOString() }];
227
- state.taskStatus = 'failed';
228
- return { content: error + '; latest text: ' + content, model: activeModel };
229
- }
230
- emit({ type: 'tool_start', tool: call.function.name, input: undefined });
231
- const output = await runTool(call, signal);
232
- const summary = output.split('\n')[0] ?? '';
233
- emit({ type: 'tool_result', tool: call.function.name, summary });
234
- results.push({ call, output });
235
- wire.push({ role: 'tool', tool_call_id: call.id, content: truncate(output) });
236
- executedAny = true;
501
+ for (const result of results) {
502
+ const tool = result.call.function.name;
503
+ state.messages = [...state.messages, { role: 'tool', content: result.output.slice(0, 500), toolName: tool, createdAt: new Date().toISOString() }];
237
504
  }
238
- if (!executedAny)
239
- break;
240
- content = '';
241
- reasoning = '';
242
- toolCalls = [];
243
- const next = await client.chatStream(state.activeModel, wire, {
244
- signal, tools: toolSchemas,
245
- onDelta: (delta) => {
246
- if (delta.type === 'reasoning') {
247
- reasoning += delta.delta;
248
- emit({ type: 'thinking_delta', delta: delta.delta });
249
- }
250
- else if (delta.type === 'text') {
251
- content += delta.delta;
252
- emit({ type: 'text_delta', delta: delta.delta });
253
- }
254
- else if (delta.type === 'tool_call') {
255
- if (!toolCalls.some((call) => call.id === delta.call.id)) {
256
- toolCalls.push(delta.call);
257
- }
258
- else {
259
- toolCalls = toolCalls.map((call) => call.id === delta.call.id ? delta.call : call);
260
- }
261
- }
262
- },
263
- });
264
- if (next.model)
265
- model = next.model;
266
- }
267
- state.taskStatus = 'completed';
268
- state.metrics = client.snapshotMetrics();
269
- const answer = content;
270
- if (reasoning) {
271
- emit({ type: 'thinking', text: reasoning });
272
- state.messages = [...state.messages, { role: 'thought', content: reasoning, createdAt: new Date().toISOString() }];
273
- }
274
- for (const result of results) {
275
- const tool = result.call.function.name;
276
- state.messages = [...state.messages, { role: 'tool', content: result.output.slice(0, 500), toolName: tool, createdAt: new Date().toISOString() }];
505
+ emit({ type: 'text', content: answer, model, compression });
506
+ state.messages = [...state.messages, { role: 'assistant', content: answer, model, createdAt: new Date().toISOString() }];
507
+ try {
508
+ await saveSession(state.workspace.root, {
509
+ messages: [...state.messages], taskQueue: [...state.taskQueue], savedAt: new Date().toISOString(),
510
+ });
511
+ }
512
+ catch { /* session persistence is best-effort */ }
513
+ return { content: answer, model };
277
514
  }
278
- emit({ type: 'text', content: answer });
279
- state.messages = [...state.messages, { role: 'assistant', content: answer, model, createdAt: new Date().toISOString() }];
280
- return { content: answer, model };
281
515
  },
282
516
  };
283
517
  }
@@ -286,7 +520,77 @@ function asWireMessage(message) {
286
520
  return { role: message.role, content: message.content };
287
521
  return { role: 'assistant', content: message.content };
288
522
  }
523
+ function mimeFromName(name) {
524
+ const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
525
+ return ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext;
526
+ }
289
527
  function truncate(text, max = 4000) {
290
528
  return text.length <= max ? text : `${text.slice(0, max)}\n… (truncated ${text.length - max} chars)`;
291
529
  }
530
+ const MAX_INDEX_FILES = 200;
531
+ const MAX_INDEX_BYTES = 256 * 1024;
532
+ /** Reuse cached embeddings for unchanged files; embed new/changed ones, batched. */
533
+ async function indexWorkspaceSemantics(client, cache, root) {
534
+ const files = [];
535
+ async function walk(directory) {
536
+ if (files.length >= MAX_INDEX_FILES)
537
+ return;
538
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
539
+ if (files.length >= MAX_INDEX_FILES)
540
+ return;
541
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
542
+ continue;
543
+ const target = resolve(directory, entry.name);
544
+ if (entry.isDirectory())
545
+ await walk(target);
546
+ else if (entry.isFile()) {
547
+ try {
548
+ const info = await stat(target);
549
+ if (info.size > 0 && info.size <= MAX_INDEX_BYTES)
550
+ files.push({ path: target, mtimeMs: info.mtimeMs });
551
+ }
552
+ catch { /* unreadable file: skip */ }
553
+ }
554
+ }
555
+ }
556
+ await walk(root);
557
+ const results = [];
558
+ const changed = new Map();
559
+ for (const file of files) {
560
+ const cached = cache.get(file.path);
561
+ if (cached && cached.mtimeMs === file.mtimeMs) {
562
+ results.push(...cached.chunks);
563
+ continue;
564
+ }
565
+ try {
566
+ const text = await readFile(file.path, 'utf8');
567
+ changed.set(file.path, { mtimeMs: file.mtimeMs, chunks: [] });
568
+ for (const piece of chunkText(text))
569
+ changed.get(file.path).chunks.push({ path: file.path, text: piece, embedding: [] });
570
+ }
571
+ catch { /* binary/unreadable: skip */ }
572
+ }
573
+ const toEmbed = [];
574
+ for (const entry of changed.values())
575
+ toEmbed.push(...entry.chunks);
576
+ for (let i = 0; i < toEmbed.length; i += 16) {
577
+ const batch = toEmbed.slice(i, i + 16);
578
+ const vectors = await client.embed(batch.map((chunk) => chunk.text));
579
+ batch.forEach((chunk, index) => { chunk.embedding = vectors[index]; });
580
+ }
581
+ for (const file of files) {
582
+ const entry = changed.get(file.path);
583
+ if (entry) {
584
+ cache.set(file.path, entry);
585
+ results.push(...entry.chunks);
586
+ }
587
+ }
588
+ // Drop cached entries for files no longer present so the persisted index stays clean.
589
+ const present = new Set(files.map((file) => file.path));
590
+ for (const path of [...cache.keys()]) {
591
+ if (!present.has(path))
592
+ cache.delete(path);
593
+ }
594
+ return results;
595
+ }
292
596
  //# sourceMappingURL=mastraEngine.js.map