Compare commits
2 Commits
345cfef425
...
feat/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66fb080297 | ||
|
|
7e28a09345 |
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": ["*.md"],
|
||||
"options": {
|
||||
"singleQuote": true
|
||||
}
|
||||
"singleQuote": true
|
||||
}
|
||||
|
||||
20
package.json
20
package.json
@@ -12,16 +12,6 @@
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/mozilla__readability": "^0.4.2",
|
||||
"oxfmt": "^0.40.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/google": "^3.0.43",
|
||||
@@ -37,5 +27,15 @@
|
||||
"ollama-ai-provider": "^1.2.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/mozilla__readability": "^0.4.2",
|
||||
"oxfmt": "^0.40.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ export class AgentLoop {
|
||||
this._model = opts.model ?? opts.provider.defaultModel;
|
||||
this._maxIterations = opts.maxIterations ?? 40;
|
||||
|
||||
const execConfig = opts.execConfig ?? { timeout: 120, denyPatterns: [], restrictToWorkspace: false };
|
||||
const execConfig = opts.execConfig ?? {
|
||||
timeout: 120,
|
||||
denyPatterns: [],
|
||||
restrictToWorkspace: false,
|
||||
};
|
||||
this._ctx = new ContextBuilder(opts.workspace);
|
||||
this._sessions = opts.sessionManager ?? new SessionManager(opts.workspace);
|
||||
|
||||
@@ -94,7 +98,11 @@ export class AgentLoop {
|
||||
restrictToWorkspace?: boolean;
|
||||
}): void {
|
||||
const allowed = opts.restrictToWorkspace ? this._workspace : undefined;
|
||||
const execConfig = opts.execConfig ?? { timeout: 120, denyPatterns: [], restrictToWorkspace: false };
|
||||
const execConfig = opts.execConfig ?? {
|
||||
timeout: 120,
|
||||
denyPatterns: [],
|
||||
restrictToWorkspace: false,
|
||||
};
|
||||
|
||||
this._tools.register(new ReadFileTool({ workspace: this._workspace, allowedDir: allowed }));
|
||||
this._tools.register(new WriteFileTool({ workspace: this._workspace, allowedDir: allowed }));
|
||||
@@ -110,9 +118,7 @@ export class AgentLoop {
|
||||
);
|
||||
this._tools.register(new WebSearchTool({ apiKey: opts.braveApiKey, proxy: opts.webProxy }));
|
||||
this._tools.register(new WebFetchTool({ proxy: opts.webProxy }));
|
||||
this._tools.register(
|
||||
new MessageTool((msg) => this._bus.publishOutbound(msg)),
|
||||
);
|
||||
this._tools.register(new MessageTool((msg) => this._bus.publishOutbound(msg)));
|
||||
this._tools.register(new SpawnTool(this._subagents));
|
||||
if (opts.cronService) {
|
||||
this._tools.register(new CronTool(opts.cronService));
|
||||
@@ -191,7 +197,12 @@ export class AgentLoop {
|
||||
if (response) {
|
||||
this._bus.publishOutbound(response);
|
||||
} else if (msg.channel === 'cli') {
|
||||
this._bus.publishOutbound({ channel: msg.channel, chatId: msg.chatId, content: '', metadata: msg.metadata });
|
||||
this._bus.publishOutbound({
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
content: '',
|
||||
metadata: msg.metadata,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
@@ -215,17 +226,32 @@ export class AgentLoop {
|
||||
): Promise<OutboundMessage | null> {
|
||||
// System messages (subagent results) routed as "system" channel
|
||||
if (msg.channel === 'system') {
|
||||
const [channel, chatId] = msg.chatId.includes(':') ? msg.chatId.split(':', 2) as [string, string] : ['cli', msg.chatId];
|
||||
const [channel, chatId] = msg.chatId.includes(':')
|
||||
? (msg.chatId.split(':', 2) as [string, string])
|
||||
: ['cli', msg.chatId];
|
||||
const key = `${channel}:${chatId}`;
|
||||
const session = this._sessions.getOrCreate(key);
|
||||
await this._consolidator.maybeConsolidateByTokens(session);
|
||||
this._setToolContext(channel, chatId);
|
||||
const messages = this._ctx.buildMessages({ history: session.getHistory(0) as Array<Record<string, unknown>>, currentMessage: msg.content, channel, chatId });
|
||||
const { finalContent, allMessages } = await this._runAgentLoop(messages as ModelMessage[], signal);
|
||||
const messages = this._ctx.buildMessages({
|
||||
history: session.getHistory(0) as Array<Record<string, unknown>>,
|
||||
currentMessage: msg.content,
|
||||
channel,
|
||||
chatId,
|
||||
});
|
||||
const { finalContent, allMessages } = await this._runAgentLoop(
|
||||
messages as ModelMessage[],
|
||||
signal,
|
||||
);
|
||||
this._saveTurn(session, allMessages, 1 + session.getHistory(0).length);
|
||||
this._sessions.save(session);
|
||||
await this._consolidator.maybeConsolidateByTokens(session);
|
||||
return { channel, chatId, content: finalContent ?? 'Background task completed.', metadata: {} };
|
||||
return {
|
||||
channel,
|
||||
chatId,
|
||||
content: finalContent ?? 'Background task completed.',
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
const preview = msg.content.length > 80 ? `${msg.content.slice(0, 80)}...` : msg.content;
|
||||
@@ -238,15 +264,31 @@ export class AgentLoop {
|
||||
const cmd = msg.content.trim().toLowerCase();
|
||||
if (cmd === '/new') {
|
||||
if (!(await this._consolidator.archiveUnconsolidated(session))) {
|
||||
return { channel: msg.channel, chatId: msg.chatId, content: 'Memory archival failed, session not cleared. Please try again.', metadata: {} };
|
||||
return {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
content: 'Memory archival failed, session not cleared. Please try again.',
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
session.clear();
|
||||
this._sessions.save(session);
|
||||
this._sessions.invalidate(session.key);
|
||||
return { channel: msg.channel, chatId: msg.chatId, content: 'New session started.', metadata: {} };
|
||||
return {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
content: 'New session started.',
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
if (cmd === '/help') {
|
||||
return { channel: msg.channel, chatId: msg.chatId, content: 'nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands', metadata: {} };
|
||||
return {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
content:
|
||||
'nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands',
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
await this._consolidator.maybeConsolidateByTokens(session);
|
||||
@@ -256,7 +298,12 @@ export class AgentLoop {
|
||||
if (msgTool instanceof MessageTool) msgTool.startTurn();
|
||||
|
||||
const history = session.getHistory(0) as Array<Record<string, unknown>>;
|
||||
const initialMessages = this._ctx.buildMessages({ history, currentMessage: msg.content, channel: msg.channel, chatId: msg.chatId });
|
||||
const initialMessages = this._ctx.buildMessages({
|
||||
history,
|
||||
currentMessage: msg.content,
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
});
|
||||
|
||||
const onProgress = async (content: string, opts?: { toolHint?: boolean }) => {
|
||||
this._bus.publishOutbound({
|
||||
@@ -267,7 +314,11 @@ export class AgentLoop {
|
||||
});
|
||||
};
|
||||
|
||||
const { finalContent, allMessages } = await this._runAgentLoop(initialMessages as ModelMessage[], signal, onProgress);
|
||||
const { finalContent, allMessages } = await this._runAgentLoop(
|
||||
initialMessages as ModelMessage[],
|
||||
signal,
|
||||
onProgress,
|
||||
);
|
||||
|
||||
this._saveTurn(session, allMessages, 1 + history.length);
|
||||
this._sessions.save(session);
|
||||
@@ -311,13 +362,18 @@ export class AgentLoop {
|
||||
if (response.toolCalls.length > 0) {
|
||||
if (onProgress) {
|
||||
if (response.content) await onProgress(response.content);
|
||||
const hint = response.toolCalls.map((tc) => {
|
||||
const firstVal = Object.values(tc.arguments)[0];
|
||||
const display = typeof firstVal === 'string'
|
||||
? (firstVal.length > 40 ? `"${firstVal.slice(0, 40)}…"` : `"${firstVal}"`)
|
||||
: '';
|
||||
return `${tc.name}(${display})`;
|
||||
}).join(', ');
|
||||
const hint = response.toolCalls
|
||||
.map((tc) => {
|
||||
const firstVal = Object.values(tc.arguments)[0];
|
||||
const display =
|
||||
typeof firstVal === 'string'
|
||||
? firstVal.length > 40
|
||||
? `"${firstVal.slice(0, 40)}…"`
|
||||
: `"${firstVal}"`
|
||||
: '';
|
||||
return `${tc.name}(${display})`;
|
||||
})
|
||||
.join(', ');
|
||||
await onProgress(hint, { toolHint: true });
|
||||
}
|
||||
|
||||
@@ -353,7 +409,11 @@ export class AgentLoop {
|
||||
if (role === 'assistant' && !content && !(entry['tool_calls'] as unknown[])?.length) continue;
|
||||
|
||||
// Truncate large tool results
|
||||
if (role === 'tool' && typeof content === 'string' && content.length > TOOL_RESULT_MAX_CHARS) {
|
||||
if (
|
||||
role === 'tool' &&
|
||||
typeof content === 'string' &&
|
||||
content.length > TOOL_RESULT_MAX_CHARS
|
||||
) {
|
||||
entry['content'] = `${content.slice(0, TOOL_RESULT_MAX_CHARS)}\n... (truncated)`;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,11 @@ export class MemoryStore {
|
||||
return mem ? `## Long-term Memory\n${mem}` : '';
|
||||
}
|
||||
|
||||
async consolidate(messages: Array<Record<string, unknown>>, provider: LLMProvider, model: string): Promise<boolean> {
|
||||
async consolidate(
|
||||
messages: Array<Record<string, unknown>>,
|
||||
provider: LLMProvider,
|
||||
model: string,
|
||||
): Promise<boolean> {
|
||||
if (messages.length === 0) return true;
|
||||
|
||||
const currentMemory = this.readLongTerm();
|
||||
@@ -104,7 +108,8 @@ export class MemoryStore {
|
||||
.map((m) => {
|
||||
const ts = typeof m['timestamp'] === 'string' ? m['timestamp'].slice(0, 16) : '?';
|
||||
const role = (typeof m['role'] === 'string' ? m['role'] : 'unknown').toUpperCase();
|
||||
const content = typeof m['content'] === 'string' ? m['content'] : JSON.stringify(m['content']);
|
||||
const content =
|
||||
typeof m['content'] === 'string' ? m['content'] : JSON.stringify(m['content']);
|
||||
return `[${ts}] ${role}: ${content}`;
|
||||
})
|
||||
.join('\n');
|
||||
@@ -140,8 +145,10 @@ ${formatted}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = typeof tc.arguments['history_entry'] === 'string' ? tc.arguments['history_entry'] : null;
|
||||
const update = typeof tc.arguments['memory_update'] === 'string' ? tc.arguments['memory_update'] : null;
|
||||
const entry =
|
||||
typeof tc.arguments['history_entry'] === 'string' ? tc.arguments['history_entry'] : null;
|
||||
const update =
|
||||
typeof tc.arguments['memory_update'] === 'string' ? tc.arguments['memory_update'] : null;
|
||||
|
||||
if (entry) this.appendHistory(entry);
|
||||
if (update && update !== currentMemory) this.writeLongTerm(update);
|
||||
@@ -165,7 +172,12 @@ export class MemoryConsolidator {
|
||||
private _model: string;
|
||||
private _sessions: SessionManager;
|
||||
private _contextWindowTokens: number;
|
||||
private _buildMessages: (opts: { history: Array<Record<string, unknown>>; currentMessage: string; channel?: string; chatId?: string }) => Array<Record<string, unknown>>;
|
||||
private _buildMessages: (opts: {
|
||||
history: Array<Record<string, unknown>>;
|
||||
currentMessage: string;
|
||||
channel?: string;
|
||||
chatId?: string;
|
||||
}) => Array<Record<string, unknown>>;
|
||||
private _getToolDefs: () => Array<Record<string, unknown>>;
|
||||
private _locks = new Map<string, Promise<void>>();
|
||||
|
||||
@@ -175,7 +187,12 @@ export class MemoryConsolidator {
|
||||
model: string;
|
||||
sessions: SessionManager;
|
||||
contextWindowTokens: number;
|
||||
buildMessages: (opts: { history: Array<Record<string, unknown>>; currentMessage: string; channel?: string; chatId?: string }) => Array<Record<string, unknown>>;
|
||||
buildMessages: (opts: {
|
||||
history: Array<Record<string, unknown>>;
|
||||
currentMessage: string;
|
||||
channel?: string;
|
||||
chatId?: string;
|
||||
}) => Array<Record<string, unknown>>;
|
||||
getToolDefs: () => Array<Record<string, unknown>>;
|
||||
}) {
|
||||
this._store = new MemoryStore(opts.workspace);
|
||||
@@ -195,15 +212,23 @@ export class MemoryConsolidator {
|
||||
// Chain promises per session key to serialize consolidation
|
||||
const prev = this._locks.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn);
|
||||
this._locks.set(key, next.catch(() => {}));
|
||||
this._locks.set(
|
||||
key,
|
||||
next.catch(() => {}),
|
||||
);
|
||||
await next;
|
||||
}
|
||||
|
||||
async archiveUnconsolidated(session: Session): Promise<boolean> {
|
||||
let ok = false;
|
||||
await this._withLock(session.key, async () => {
|
||||
const snapshot = session.messages.slice(session.lastConsolidated) as Array<Record<string, unknown>>;
|
||||
if (snapshot.length === 0) { ok = true; return; }
|
||||
const snapshot = session.messages.slice(session.lastConsolidated) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
if (snapshot.length === 0) {
|
||||
ok = true;
|
||||
return;
|
||||
}
|
||||
ok = await this._store.consolidate(snapshot, this._provider, this._model);
|
||||
});
|
||||
return ok;
|
||||
@@ -219,7 +244,8 @@ export class MemoryConsolidator {
|
||||
const history = session.getHistory(0) as Array<Record<string, unknown>>;
|
||||
const probe = this._buildMessages({ history, currentMessage: '[token-probe]' });
|
||||
const toolTokens = estimateTokens(JSON.stringify(this._getToolDefs()));
|
||||
const estimated = estimateMessagesTokens(probe as Array<Record<string, unknown>>) + toolTokens;
|
||||
const estimated =
|
||||
estimateMessagesTokens(probe as Array<Record<string, unknown>>) + toolTokens;
|
||||
|
||||
if (estimated < this._contextWindowTokens) return; // fits — done
|
||||
|
||||
@@ -227,10 +253,14 @@ export class MemoryConsolidator {
|
||||
const boundary = this._pickBoundary(session, Math.max(1, estimated - target));
|
||||
if (boundary === null) return;
|
||||
|
||||
const chunk = session.messages.slice(session.lastConsolidated, boundary) as Array<Record<string, unknown>>;
|
||||
const chunk = session.messages.slice(session.lastConsolidated, boundary) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
if (chunk.length === 0) return;
|
||||
|
||||
console.info(`[memory] Token consolidation round ${round}: ~${estimated} tokens, chunk=${chunk.length} msgs`);
|
||||
console.info(
|
||||
`[memory] Token consolidation round ${round}: ~${estimated} tokens, chunk=${chunk.length} msgs`,
|
||||
);
|
||||
if (!(await this._store.consolidate(chunk, this._provider, this._model))) return;
|
||||
|
||||
session.lastConsolidated = boundary;
|
||||
|
||||
@@ -138,7 +138,10 @@ export class SkillsLoader {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
|
||||
const val = line
|
||||
.slice(colon + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '');
|
||||
if (key === 'description') meta.description = val;
|
||||
if (key === 'always') meta.always = val === 'true';
|
||||
if (key === 'metadata') meta.metadata = val;
|
||||
|
||||
@@ -55,12 +55,16 @@ export class CronTool implements Tool {
|
||||
case 'enable': {
|
||||
const id = strArg(args, 'id');
|
||||
if (!id) return 'Error: id is required for enable.';
|
||||
return this._service.enableJob(id, true) ? `Job ${id} enabled.` : `Error: job ${id} not found.`;
|
||||
return this._service.enableJob(id, true)
|
||||
? `Job ${id} enabled.`
|
||||
: `Error: job ${id} not found.`;
|
||||
}
|
||||
case 'disable': {
|
||||
const id = strArg(args, 'id');
|
||||
if (!id) return 'Error: id is required for disable.';
|
||||
return this._service.enableJob(id, false) ? `Job ${id} disabled.` : `Error: job ${id} not found.`;
|
||||
return this._service.enableJob(id, false)
|
||||
? `Job ${id} disabled.`
|
||||
: `Error: job ${id} not found.`;
|
||||
}
|
||||
case 'run': {
|
||||
const id = strArg(args, 'id');
|
||||
|
||||
@@ -5,7 +5,16 @@ import type { Tool } from './base.ts';
|
||||
|
||||
const MAX_READ_CHARS = 128_000;
|
||||
const MAX_ENTRIES = 2000;
|
||||
const IGNORED_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', '.next', 'build']);
|
||||
const IGNORED_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'dist',
|
||||
'.next',
|
||||
'build',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_file
|
||||
@@ -55,7 +64,10 @@ export class ReadFileTool implements Tool {
|
||||
const slice = lines.slice(start, end);
|
||||
|
||||
const numbered = slice.map((l, i) => `${start + i + 1}: ${l}`).join('\n');
|
||||
const truncated = numbered.length > MAX_READ_CHARS ? numbered.slice(0, MAX_READ_CHARS) + '\n... (truncated)' : numbered;
|
||||
const truncated =
|
||||
numbered.length > MAX_READ_CHARS
|
||||
? numbered.slice(0, MAX_READ_CHARS) + '\n... (truncated)'
|
||||
: numbered;
|
||||
|
||||
const totalLines = lines.length;
|
||||
const header = `File: ${absPath} (${totalLines} lines total)\n`;
|
||||
@@ -160,7 +172,7 @@ export class EditFileTool implements Tool {
|
||||
let updated: string;
|
||||
if (replaceAll) {
|
||||
updated = content.split(oldString).join(newString);
|
||||
count = (content.split(oldString).length - 1);
|
||||
count = content.split(oldString).length - 1;
|
||||
} else {
|
||||
const idx = content.indexOf(oldString);
|
||||
if (idx === -1) return `Error: oldString not found in ${absPath}.`;
|
||||
|
||||
@@ -7,12 +7,7 @@ const DEFAULT_TIMEOUT_S = 120;
|
||||
const MAX_TIMEOUT_S = 600;
|
||||
const OUTPUT_MAX_CHARS = 32_000;
|
||||
|
||||
const DEFAULT_DENY_PATTERNS = [
|
||||
/rm\s+-rf\s+\/(?!\S)/,
|
||||
/mkfs/,
|
||||
/dd\s+if=/,
|
||||
/:\(\)\s*\{.*\}/,
|
||||
];
|
||||
const DEFAULT_DENY_PATTERNS = [/rm\s+-rf\s+\/(?!\S)/, /mkfs/, /dd\s+if=/, /:\(\)\s*\{.*\}/];
|
||||
|
||||
export class ExecTool implements Tool {
|
||||
readonly name = 'exec';
|
||||
|
||||
@@ -7,7 +7,10 @@ export class SpawnTool implements Tool {
|
||||
readonly description =
|
||||
'Spawn a background subagent to handle a long-running task autonomously. The subagent has access to filesystem, shell, and web tools. It will report its result back when done.';
|
||||
readonly parameters = {
|
||||
task: { type: 'string', description: 'Full description of the task for the subagent to complete.' },
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'Full description of the task for the subagent to complete.',
|
||||
},
|
||||
};
|
||||
readonly required = ['task'];
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ const MAX_CONTENT_CHARS = 50_000;
|
||||
|
||||
export class WebSearchTool implements Tool {
|
||||
readonly name = 'web_search';
|
||||
readonly description = 'Search the web using Brave Search. Returns a list of results with titles, URLs, and snippets.';
|
||||
readonly description =
|
||||
'Search the web using Brave Search. Returns a list of results with titles, URLs, and snippets.';
|
||||
readonly parameters = {
|
||||
query: { type: 'string', description: 'Search query.' },
|
||||
count: { type: 'number', description: 'Number of results (default 10, max 20).' },
|
||||
@@ -30,7 +31,8 @@ export class WebSearchTool implements Tool {
|
||||
async execute(args: Record<string, unknown>): Promise<string> {
|
||||
const query = strArg(args, 'query').trim();
|
||||
if (!query) return 'Error: query is required.';
|
||||
if (!this._apiKey) return 'Error: BRAVE_API_KEY not configured (set tools.web.braveApiKey in config).';
|
||||
if (!this._apiKey)
|
||||
return 'Error: BRAVE_API_KEY not configured (set tools.web.braveApiKey in config).';
|
||||
|
||||
const count = Math.min(Number(args['count'] ?? 10), 20);
|
||||
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
||||
@@ -38,7 +40,7 @@ export class WebSearchTool implements Tool {
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': this._apiKey,
|
||||
},
|
||||
@@ -46,7 +48,9 @@ export class WebSearchTool implements Tool {
|
||||
|
||||
if (!res.ok) return `Error: Brave Search API returned ${res.status}: ${await res.text()}`;
|
||||
|
||||
const data = (await res.json()) as { web?: { results?: Array<{ title: string; url: string; description: string }> } };
|
||||
const data = (await res.json()) as {
|
||||
web?: { results?: Array<{ title: string; url: string; description: string }> };
|
||||
};
|
||||
const results = data.web?.results ?? [];
|
||||
|
||||
if (results.length === 0) return 'No results found.';
|
||||
@@ -70,7 +74,11 @@ export class WebFetchTool implements Tool {
|
||||
'Fetch a URL and return its content. HTML pages are extracted to readable text. Use mode="raw" for JSON/XML/plain text.';
|
||||
readonly parameters = {
|
||||
url: { type: 'string', description: 'URL to fetch.' },
|
||||
mode: { type: 'string', enum: ['markdown', 'text', 'raw'], description: 'Output mode (default: text).' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['markdown', 'text', 'raw'],
|
||||
description: 'Output mode (default: text).',
|
||||
},
|
||||
};
|
||||
readonly required = ['url'];
|
||||
|
||||
@@ -96,8 +104,14 @@ export class WebFetchTool implements Tool {
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const body = await res.text();
|
||||
|
||||
if (mode === 'raw' || (!contentType.includes('text/html') && !body.trimStart().startsWith('<'))) {
|
||||
const truncated = body.length > MAX_CONTENT_CHARS ? body.slice(0, MAX_CONTENT_CHARS) + '\n... (truncated)' : body;
|
||||
if (
|
||||
mode === 'raw' ||
|
||||
(!contentType.includes('text/html') && !body.trimStart().startsWith('<'))
|
||||
) {
|
||||
const truncated =
|
||||
body.length > MAX_CONTENT_CHARS
|
||||
? body.slice(0, MAX_CONTENT_CHARS) + '\n... (truncated)'
|
||||
: body;
|
||||
return truncated;
|
||||
}
|
||||
|
||||
@@ -114,9 +128,10 @@ export class WebFetchTool implements Tool {
|
||||
const title = article?.title ?? '';
|
||||
const textContent = article?.textContent ?? stripTags(body);
|
||||
const trimmed = textContent.replace(/\n{3,}/g, '\n\n').trim();
|
||||
const truncated = trimmed.length > MAX_CONTENT_CHARS
|
||||
? trimmed.slice(0, MAX_CONTENT_CHARS) + '\n... (truncated)'
|
||||
: trimmed;
|
||||
const truncated =
|
||||
trimmed.length > MAX_CONTENT_CHARS
|
||||
? trimmed.slice(0, MAX_CONTENT_CHARS) + '\n... (truncated)'
|
||||
: trimmed;
|
||||
|
||||
return title ? `# ${title}\n\n${truncated}` : truncated;
|
||||
} catch (err) {
|
||||
@@ -136,7 +151,10 @@ function fetchWithTimeout(url: string, init: RequestInit = {}): Promise<Response
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Build a minimal pseudo-document that satisfies Readability's interface. */
|
||||
@@ -166,7 +184,9 @@ function makePseudoDocument(
|
||||
createTreeWalker: () => ({ nextNode: () => null }),
|
||||
createRange: () => ({ selectNodeContents: () => {}, cloneContents: () => null }),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Readability duck-typing
|
||||
get innerHTML() { return html; },
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
location: { href: url },
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export class ChannelManager {
|
||||
}
|
||||
|
||||
const content = msg.content ?? '';
|
||||
const chatId = msg.metadata?.['channel_id'] as string | undefined ?? msg.chatId;
|
||||
const chatId = (msg.metadata?.['channel_id'] as string | undefined) ?? msg.chatId;
|
||||
const rootId = msg.metadata?.['root_id'] as string | undefined;
|
||||
|
||||
try {
|
||||
|
||||
@@ -165,7 +165,11 @@ export class MattermostChannel extends BaseChannel {
|
||||
} else {
|
||||
// Group channel
|
||||
if (!this._shouldRespondInGroup(post.message, this._cfg.groupPolicy)) return;
|
||||
if (this._cfg.groupPolicy === 'allowlist' && !this.isAllowed(post.user_id, this._cfg.groupAllowFrom)) return;
|
||||
if (
|
||||
this._cfg.groupPolicy === 'allowlist' &&
|
||||
!this.isAllowed(post.user_id, this._cfg.groupAllowFrom)
|
||||
)
|
||||
return;
|
||||
if (!this.isAllowed(post.user_id, this._cfg.allowFrom)) return;
|
||||
}
|
||||
|
||||
@@ -226,7 +230,7 @@ export class MattermostChannel extends BaseChannel {
|
||||
const res = await fetch(`${this._baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this._cfg.token}`,
|
||||
Authorization: `Bearer ${this._cfg.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
|
||||
92
src/cli/agent.ts
Normal file
92
src/cli/agent.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
import { AgentLoop } from '../agent/loop.ts';
|
||||
import { MessageBus } from '../bus/queue.ts';
|
||||
import type { Config } from '../config/types.ts';
|
||||
import { makeProvider } from '../provider/index.ts';
|
||||
|
||||
export function agentCommand(program: Command, config: Config, workspace: string): void {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
program
|
||||
.command('agent')
|
||||
.description('Run the agent interactively or send a single message.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.option('-m, --message <text>', 'Single message to process (non-interactive)')
|
||||
.option('-w, --workspace <path>', 'Workspace path override')
|
||||
.option('-M, --model <model>', 'Model override')
|
||||
.action(
|
||||
async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => {
|
||||
const model = opts.model ?? config.agent.model;
|
||||
const provider = makeProvider(
|
||||
config.providers,
|
||||
model,
|
||||
config.agent.maxTokens,
|
||||
config.agent.temperature,
|
||||
);
|
||||
const bus = new MessageBus();
|
||||
|
||||
const agentLoop = new AgentLoop({
|
||||
bus,
|
||||
provider,
|
||||
workspace,
|
||||
model,
|
||||
maxIterations: config.agent.maxToolIterations,
|
||||
contextWindowTokens: config.agent.contextWindowTokens,
|
||||
braveApiKey: config.tools.web.braveApiKey,
|
||||
webProxy: config.tools.web.proxy,
|
||||
execConfig: config.tools.exec,
|
||||
restrictToWorkspace: config.tools.restrictToWorkspace,
|
||||
});
|
||||
|
||||
// Single-shot mode
|
||||
if (opts.message) {
|
||||
const result = await agentLoop.processDirect(opts.message);
|
||||
console.log(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive mode
|
||||
console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.'));
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
const promptUser = () => {
|
||||
rl.question(pc.cyan('You: '), async (input) => {
|
||||
const text = input.trim();
|
||||
if (!text) {
|
||||
promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
const onProgress = async (content: string, opts?: { toolHint?: boolean }) => {
|
||||
if (opts?.toolHint) {
|
||||
process.stdout.write(pc.dim(` [${content}]\n`));
|
||||
} else {
|
||||
process.stdout.write(pc.dim(` ${content}\n`));
|
||||
}
|
||||
};
|
||||
|
||||
const result = await agentLoop.processDirect(
|
||||
text,
|
||||
'cli:interactive',
|
||||
'cli',
|
||||
'interactive',
|
||||
onProgress,
|
||||
);
|
||||
console.log(pc.bold('Bot:'), result);
|
||||
promptUser();
|
||||
});
|
||||
};
|
||||
|
||||
rl.on('close', () => {
|
||||
agentLoop.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
promptUser();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,178 +1,21 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
import { AgentLoop } from '../agent/loop.ts';
|
||||
import { MessageBus } from '../bus/queue.ts';
|
||||
import { MattermostChannel } from '../channels/mattermost.ts';
|
||||
import { ChannelManager } from '../channels/manager.ts';
|
||||
import { loadConfig, resolveWorkspacePath } from '../config/loader.ts';
|
||||
import { CronService } from '../cron/service.ts';
|
||||
import { HeartbeatService } from '../heartbeat/service.ts';
|
||||
import { makeProvider } from '../provider/index.ts';
|
||||
import { agentCommand } from './agent.ts';
|
||||
import { gatewayCommand } from './gateway.ts';
|
||||
|
||||
export function createCli(): Command {
|
||||
const program = new Command('nanobot').description('nanobot — personal AI assistant').version('1.0.0');
|
||||
const program = new Command('nanobot')
|
||||
.description('nanobot — personal AI assistant')
|
||||
.version('1.0.0');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gateway — full runtime: Mattermost + cron + heartbeat
|
||||
// ---------------------------------------------------------------------------
|
||||
program
|
||||
.command('gateway')
|
||||
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.action(async (opts: { config?: string }) => {
|
||||
const config = loadConfig(opts.config);
|
||||
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
const globalOpts = program.opts();
|
||||
const config = loadConfig(globalOpts.config);
|
||||
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
const provider = makeProvider(config.providers, config.agent.model, config.agent.maxTokens, config.agent.temperature);
|
||||
const bus = new MessageBus();
|
||||
const channelManager = new ChannelManager(bus);
|
||||
|
||||
// Cron service
|
||||
const cronService = new CronService(workspace, async (job) => {
|
||||
bus.publishInbound({
|
||||
channel: 'system',
|
||||
senderId: 'cron',
|
||||
chatId: `cli:cron_${job.id}`,
|
||||
content: job.payload.message || `Cron job "${job.name}" triggered.`,
|
||||
metadata: { cronJobId: job.id },
|
||||
});
|
||||
});
|
||||
|
||||
const agentLoop = new AgentLoop({
|
||||
bus,
|
||||
provider,
|
||||
workspace,
|
||||
model: config.agent.model,
|
||||
maxIterations: config.agent.maxToolIterations,
|
||||
contextWindowTokens: config.agent.contextWindowTokens,
|
||||
braveApiKey: config.tools.web.braveApiKey,
|
||||
webProxy: config.tools.web.proxy,
|
||||
execConfig: config.tools.exec,
|
||||
cronService,
|
||||
restrictToWorkspace: config.tools.restrictToWorkspace,
|
||||
sendProgress: config.channels.sendProgress,
|
||||
sendToolHints: config.channels.sendToolHints,
|
||||
});
|
||||
|
||||
// Mattermost
|
||||
if (config.channels.mattermost) {
|
||||
const mm = new MattermostChannel(bus, config.channels.mattermost);
|
||||
channelManager.register(mm);
|
||||
} else {
|
||||
console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.'));
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
let heartbeat: HeartbeatService | null = null;
|
||||
if (config.heartbeat.enabled) {
|
||||
heartbeat = new HeartbeatService({
|
||||
workspace,
|
||||
provider,
|
||||
model: config.agent.model,
|
||||
intervalMinutes: config.heartbeat.intervalMinutes,
|
||||
onExecute: async (tasks) => {
|
||||
const content = tasks.length > 0 ? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}` : 'Heartbeat tick — check for anything to do.';
|
||||
return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat');
|
||||
},
|
||||
onNotify: async (_result) => {
|
||||
// Result already delivered via processDirect / message tool
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
console.info('\n[gateway] Shutting down...');
|
||||
agentLoop.stop();
|
||||
channelManager.stopAll();
|
||||
heartbeat?.stop();
|
||||
cronService.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.info(pc.green('[gateway] Starting...'));
|
||||
cronService.start();
|
||||
heartbeat?.start();
|
||||
|
||||
await Promise.all([agentLoop.run(), channelManager.startAll()]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent — interactive CLI or single-shot mode
|
||||
// ---------------------------------------------------------------------------
|
||||
program
|
||||
.command('agent')
|
||||
.description('Run the agent interactively or send a single message.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.option('-m, --message <text>', 'Single message to process (non-interactive)')
|
||||
.option('-w, --workspace <path>', 'Workspace path override')
|
||||
.option('-M, --model <model>', 'Model override')
|
||||
.action(async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => {
|
||||
const config = loadConfig(opts.config);
|
||||
const workspaceRaw = opts.workspace ?? config.agent.workspacePath;
|
||||
const workspace = resolveWorkspacePath(workspaceRaw);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
const model = opts.model ?? config.agent.model;
|
||||
const provider = makeProvider(config.providers, model, config.agent.maxTokens, config.agent.temperature);
|
||||
const bus = new MessageBus();
|
||||
|
||||
const agentLoop = new AgentLoop({
|
||||
bus,
|
||||
provider,
|
||||
workspace,
|
||||
model,
|
||||
maxIterations: config.agent.maxToolIterations,
|
||||
contextWindowTokens: config.agent.contextWindowTokens,
|
||||
braveApiKey: config.tools.web.braveApiKey,
|
||||
webProxy: config.tools.web.proxy,
|
||||
execConfig: config.tools.exec,
|
||||
restrictToWorkspace: config.tools.restrictToWorkspace,
|
||||
});
|
||||
|
||||
// Single-shot mode
|
||||
if (opts.message) {
|
||||
const result = await agentLoop.processDirect(opts.message);
|
||||
console.log(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive mode
|
||||
console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.'));
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
const promptUser = () => {
|
||||
rl.question(pc.cyan('You: '), async (input) => {
|
||||
const text = input.trim();
|
||||
if (!text) { promptUser(); return; }
|
||||
|
||||
const onProgress = async (content: string, opts?: { toolHint?: boolean }) => {
|
||||
if (opts?.toolHint) {
|
||||
process.stdout.write(pc.dim(` [${content}]\n`));
|
||||
} else {
|
||||
process.stdout.write(pc.dim(` ${content}\n`));
|
||||
}
|
||||
};
|
||||
|
||||
const result = await agentLoop.processDirect(text, 'cli:interactive', 'cli', 'interactive', onProgress);
|
||||
console.log(pc.bold('Bot:'), result);
|
||||
promptUser();
|
||||
});
|
||||
};
|
||||
|
||||
rl.on('close', () => {
|
||||
agentLoop.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
promptUser();
|
||||
});
|
||||
gatewayCommand(program, config, workspace);
|
||||
agentCommand(program, config, workspace);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
104
src/cli/gateway.ts
Normal file
104
src/cli/gateway.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
import { AgentLoop } from '../agent/loop.ts';
|
||||
import { MessageBus } from '../bus/queue.ts';
|
||||
import { MattermostChannel } from '../channels/mattermost.ts';
|
||||
import { ChannelManager } from '../channels/manager.ts';
|
||||
import type { Config } from '../config/types.ts';
|
||||
import { CronService } from '../cron/service.ts';
|
||||
import { HeartbeatService } from '../heartbeat/service.ts';
|
||||
import { makeProvider } from '../provider/index.ts';
|
||||
|
||||
export function gatewayCommand(program: Command, config: Config, workspace: string): void {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
|
||||
program
|
||||
.command('gateway')
|
||||
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
|
||||
.option('-c, --config <path>', 'Path to config.json')
|
||||
.action(async (_opts: { config?: string }) => {
|
||||
const provider = makeProvider(
|
||||
config.providers,
|
||||
config.agent.model,
|
||||
config.agent.maxTokens,
|
||||
config.agent.temperature,
|
||||
);
|
||||
const bus = new MessageBus();
|
||||
const channelManager = new ChannelManager(bus);
|
||||
|
||||
// Cron service
|
||||
const cronService = new CronService(workspace, async (job) => {
|
||||
bus.publishInbound({
|
||||
channel: 'system',
|
||||
senderId: 'cron',
|
||||
chatId: `cli:cron_${job.id}`,
|
||||
content: job.payload.message || `Cron job "${job.name}" triggered.`,
|
||||
metadata: { cronJobId: job.id },
|
||||
});
|
||||
});
|
||||
|
||||
const agentLoop = new AgentLoop({
|
||||
bus,
|
||||
provider,
|
||||
workspace,
|
||||
model: config.agent.model,
|
||||
maxIterations: config.agent.maxToolIterations,
|
||||
contextWindowTokens: config.agent.contextWindowTokens,
|
||||
braveApiKey: config.tools.web.braveApiKey,
|
||||
webProxy: config.tools.web.proxy,
|
||||
execConfig: config.tools.exec,
|
||||
cronService,
|
||||
restrictToWorkspace: config.tools.restrictToWorkspace,
|
||||
sendProgress: config.channels.sendProgress,
|
||||
sendToolHints: config.channels.sendToolHints,
|
||||
});
|
||||
|
||||
// Mattermost
|
||||
if (config.channels.mattermost) {
|
||||
const mm = new MattermostChannel(bus, config.channels.mattermost);
|
||||
channelManager.register(mm);
|
||||
} else {
|
||||
console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.'));
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
let heartbeat: HeartbeatService | null = null;
|
||||
if (config.heartbeat.enabled) {
|
||||
heartbeat = new HeartbeatService({
|
||||
workspace,
|
||||
provider,
|
||||
model: config.agent.model,
|
||||
intervalMinutes: config.heartbeat.intervalMinutes,
|
||||
onExecute: async (tasks) => {
|
||||
const content =
|
||||
tasks.length > 0
|
||||
? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}`
|
||||
: 'Heartbeat tick — check for anything to do.';
|
||||
return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat');
|
||||
},
|
||||
onNotify: async (_result) => {
|
||||
// Result already delivered via processDirect / message tool
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
console.info('\n[gateway] Shutting down...');
|
||||
agentLoop.stop();
|
||||
channelManager.stopAll();
|
||||
heartbeat?.stop();
|
||||
cronService.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.info(pc.green('[gateway] Starting...'));
|
||||
cronService.start();
|
||||
heartbeat?.start();
|
||||
|
||||
await Promise.all([agentLoop.run(), channelManager.startAll()]);
|
||||
});
|
||||
}
|
||||
4
src/cli/types.ts
Normal file
4
src/cli/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Command } from 'commander';
|
||||
import type { Config } from '../config/types.ts';
|
||||
|
||||
export type CommandHandler = (program: Command, config: Config, workspace: string) => void;
|
||||
@@ -87,7 +87,11 @@ export const WebToolConfigSchema = z.object({
|
||||
export type WebToolConfig = z.infer<typeof WebToolConfigSchema>;
|
||||
|
||||
export const ToolsConfigSchema = z.object({
|
||||
exec: ExecToolConfigSchema.default(() => ({ timeout: 120, denyPatterns: [], restrictToWorkspace: false })),
|
||||
exec: ExecToolConfigSchema.default(() => ({
|
||||
timeout: 120,
|
||||
denyPatterns: [],
|
||||
restrictToWorkspace: false,
|
||||
})),
|
||||
web: WebToolConfigSchema.default(() => ({})),
|
||||
restrictToWorkspace: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -125,7 +125,11 @@ export class CronService {
|
||||
if (delayMs === null) return;
|
||||
|
||||
const nextRunAtMs = Date.now() + delayMs;
|
||||
const updated: CronJob = { ...job, state: { ...job.state, nextRunAtMs }, updatedAtMs: Date.now() };
|
||||
const updated: CronJob = {
|
||||
...job,
|
||||
state: { ...job.state, nextRunAtMs },
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
this._jobs.set(job.id, updated);
|
||||
this._save();
|
||||
|
||||
@@ -159,7 +163,12 @@ export class CronService {
|
||||
} catch (err) {
|
||||
const updated: CronJob = {
|
||||
...job,
|
||||
state: { ...job.state, lastRunAtMs: Date.now(), lastStatus: 'error', lastError: String(err) },
|
||||
state: {
|
||||
...job.state,
|
||||
lastRunAtMs: Date.now(),
|
||||
lastStatus: 'error',
|
||||
lastError: String(err),
|
||||
},
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
this._jobs.set(job.id, updated);
|
||||
|
||||
@@ -29,8 +29,17 @@ export const CronJobSchema = z.object({
|
||||
name: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
schedule: CronScheduleSchema,
|
||||
payload: CronPayloadSchema.default(() => ({ kind: 'agent_turn' as const, message: '', deliver: false })),
|
||||
state: CronJobStateSchema.default(() => ({ nextRunAtMs: null, lastRunAtMs: null, lastStatus: null, lastError: null })),
|
||||
payload: CronPayloadSchema.default(() => ({
|
||||
kind: 'agent_turn' as const,
|
||||
message: '',
|
||||
deliver: false,
|
||||
})),
|
||||
state: CronJobStateSchema.default(() => ({
|
||||
nextRunAtMs: null,
|
||||
lastRunAtMs: null,
|
||||
lastStatus: null,
|
||||
lastError: null,
|
||||
})),
|
||||
createdAtMs: z.number().int().default(0),
|
||||
updatedAtMs: z.number().int().default(0),
|
||||
deleteAfterRun: z.boolean().default(false),
|
||||
|
||||
@@ -117,9 +117,11 @@ export class HeartbeatService {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = typeof decision.arguments['action'] === 'string' ? decision.arguments['action'] : 'skip';
|
||||
const action =
|
||||
typeof decision.arguments['action'] === 'string' ? decision.arguments['action'] : 'skip';
|
||||
if (action !== 'run') {
|
||||
const reason = typeof decision.arguments['reason'] === 'string' ? decision.arguments['reason'] : '';
|
||||
const reason =
|
||||
typeof decision.arguments['reason'] === 'string' ? decision.arguments['reason'] : '';
|
||||
console.debug(`[heartbeat] Decision: skip (${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,12 @@ export class LLMProvider {
|
||||
private _maxTokens: number;
|
||||
private _temperature: number;
|
||||
|
||||
constructor(providers: ProvidersConfig, defaultModel: string, maxTokens = 4096, temperature = 0.7) {
|
||||
constructor(
|
||||
providers: ProvidersConfig,
|
||||
defaultModel: string,
|
||||
maxTokens = 4096,
|
||||
temperature = 0.7,
|
||||
) {
|
||||
this._providers = providers;
|
||||
this._defaultModel = defaultModel;
|
||||
this._maxTokens = maxTokens;
|
||||
@@ -106,7 +111,9 @@ export class LLMProvider {
|
||||
case 'ollama': {
|
||||
const cfg = this._providers.ollama;
|
||||
// ollama-ai-provider returns LanguageModelV1; cast to LanguageModel (compatible at runtime)
|
||||
return createOllama({ baseURL: cfg?.apiBase ?? 'http://localhost:11434/api' })(remainder) as unknown as LanguageModel;
|
||||
return createOllama({ baseURL: cfg?.apiBase ?? 'http://localhost:11434/api' })(
|
||||
remainder,
|
||||
) as unknown as LanguageModel;
|
||||
}
|
||||
default: {
|
||||
// No recognized prefix — fall through to openai-compatible
|
||||
@@ -116,7 +123,9 @@ export class LLMProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async chat(opts: ChatOptions): Promise<{ response: LLMResponse; responseMessages: ModelMessage[] }> {
|
||||
async chat(
|
||||
opts: ChatOptions,
|
||||
): Promise<{ response: LLMResponse; responseMessages: ModelMessage[] }> {
|
||||
const model = this._resolveModel(opts.model ?? this._defaultModel);
|
||||
const maxTokens = opts.maxTokens ?? this._maxTokens;
|
||||
const temperature = opts.temperature ?? this._temperature;
|
||||
@@ -142,7 +151,12 @@ export class LLMProvider {
|
||||
messages: opts.messages as ModelMessage[],
|
||||
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tools type is complex
|
||||
tools: aiTools as any,
|
||||
toolChoice: opts.toolChoice === 'required' ? 'required' : opts.toolChoice === 'none' ? 'none' : 'auto',
|
||||
toolChoice:
|
||||
opts.toolChoice === 'required'
|
||||
? 'required'
|
||||
: opts.toolChoice === 'none'
|
||||
? 'none'
|
||||
: 'auto',
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature,
|
||||
stopWhen: stepCountIs(1),
|
||||
@@ -182,7 +196,9 @@ export class LLMProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async chatWithRetry(opts: ChatOptions): Promise<{ response: LLMResponse; responseMessages: ModelMessage[] }> {
|
||||
async chatWithRetry(
|
||||
opts: ChatOptions,
|
||||
): Promise<{ response: LLMResponse; responseMessages: ModelMessage[] }> {
|
||||
for (const delay of RETRY_DELAYS_MS) {
|
||||
const result = await this.chat(opts);
|
||||
if (result.response.finishReason !== 'error') return result;
|
||||
@@ -207,7 +223,11 @@ export function makeProvider(
|
||||
}
|
||||
|
||||
/** Build a tool-result message to append after executing a tool call. */
|
||||
export function toolResultMessage(toolCallId: string, toolName: string, result: string): ModelMessage {
|
||||
export function toolResultMessage(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: string,
|
||||
): ModelMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: [
|
||||
|
||||
@@ -126,10 +126,7 @@ export class SessionManager {
|
||||
|
||||
save(session: Session): void {
|
||||
session.updatedAt = new Date().toISOString();
|
||||
const lines = [
|
||||
JSON.stringify(session.meta),
|
||||
...session.messages.map((m) => JSON.stringify(m)),
|
||||
];
|
||||
const lines = [JSON.stringify(session.meta), ...session.messages.map((m) => JSON.stringify(m))];
|
||||
writeFileSync(this._filePath(session.key), lines.join('\n') + '\n', 'utf8');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user