Compare commits
4 Commits
feat/skill
...
398b98393a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398b98393a | ||
|
|
2d99d17d60 | ||
|
|
3893d88365 | ||
|
|
4f54c9837f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# editors
|
||||||
|
.vscode
|
||||||
|
.openvscode-server
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"correctness": "warn"
|
"correctness": "warn"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"eslint/no-unused-vars": "error"
|
"eslint/no-unused-vars": "error",
|
||||||
|
"unicorn/no-nested-ternary": "error"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"typeAware": true,
|
"typeAware": true,
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -42,7 +42,7 @@ mkdir -p ~/.nanobot
|
|||||||
**2. Chat**
|
**2. Chat**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run start agent
|
bun run nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it.
|
That's it.
|
||||||
@@ -54,22 +54,21 @@ That's it.
|
|||||||
Chat with the agent from your terminal. Does not require a running gateway.
|
Chat with the agent from your terminal. Does not require a running gateway.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun run start agent [options]
|
bun run nanobot agent [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
|
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
|
||||||
| `-m, --message <text>` | Send a single message and exit (non-interactive) |
|
| `-m, --message <text>` | Send a single message and exit (non-interactive) |
|
||||||
| `-w, --workspace <path>` | Override the workspace directory |
|
|
||||||
| `-M, --model <model>` | Override the model for this session |
|
| `-M, --model <model>` | Override the model for this session |
|
||||||
|
|
||||||
**Interactive mode** (default when no `-m` is given):
|
**Interactive mode** (default when no `-m` is given):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run start agent
|
bun run nanobot agent
|
||||||
bun run start agent -c ~/.nanobot-work/config.json
|
bun run nanobot agent -c ~/.nanobot-work/config.json
|
||||||
bun run start agent -w /tmp/scratch
|
bun run nanobot agent -w /tmp/scratch
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `Ctrl+C` to exit.
|
Press `Ctrl+C` to exit.
|
||||||
@@ -77,8 +76,8 @@ Press `Ctrl+C` to exit.
|
|||||||
**Single-shot mode:**
|
**Single-shot mode:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run start agent -m "What time is it in Tokyo?"
|
bun run nanobot agent -m "What time is it in Tokyo?"
|
||||||
bun run start agent -m "Summarize the file ./notes.md"
|
bun run nanobot agent -m "Summarize the file ./notes.md"
|
||||||
```
|
```
|
||||||
|
|
||||||
### `gateway` — Mattermost bot
|
### `gateway` — Mattermost bot
|
||||||
@@ -86,7 +85,7 @@ bun run start agent -m "Summarize the file ./notes.md"
|
|||||||
Runs the full stack: Mattermost WebSocket channel, agent loop, cron scheduler, and heartbeat.
|
Runs the full stack: Mattermost WebSocket channel, agent loop, cron scheduler, and heartbeat.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun run start gateway [options]
|
bun run nanobot gateway [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
@@ -94,8 +93,8 @@ bun run start gateway [options]
|
|||||||
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
|
| `-c, --config <path>` | Path to `config.json` (default: `~/.nanobot/config.json`) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run start gateway
|
bun run nanobot gateway
|
||||||
bun run start gateway -c ~/.nanobot-work/config.json
|
bun run nanobot gateway -c ~/.nanobot-work/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Handles `SIGINT` / `SIGTERM` for graceful shutdown.
|
Handles `SIGINT` / `SIGTERM` for graceful shutdown.
|
||||||
@@ -110,7 +109,6 @@ Environment variable overrides:
|
|||||||
|----------|-------------------|
|
|----------|-------------------|
|
||||||
| `NANOBOT_CONFIG` | path to config file |
|
| `NANOBOT_CONFIG` | path to config file |
|
||||||
| `NANOBOT_MODEL` | `agent.model` |
|
| `NANOBOT_MODEL` | `agent.model` |
|
||||||
| `NANOBOT_WORKSPACE` | `agent.workspacePath` |
|
|
||||||
|
|
||||||
### Full config reference
|
### Full config reference
|
||||||
|
|
||||||
@@ -198,7 +196,7 @@ For Ollama, set `providers.ollama.apiBase` (default: `http://localhost:11434/api
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Run `bun run start gateway`
|
4. Run `bun run nanobot gateway`
|
||||||
|
|
||||||
`allowFrom` controls which users the bot responds to. Use `["*"]` to allow all users.
|
`allowFrom` controls which users the bot responds to. Use `["*"]` to allow all users.
|
||||||
|
|
||||||
@@ -233,10 +231,10 @@ Run separate instances with different configs — useful for isolated workspaces
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Instance A
|
# Instance A
|
||||||
bun run start gateway -c ~/.nanobot-a/config.json
|
bun run nanobot gateway -c ~/.nanobot-a/config.json
|
||||||
|
|
||||||
# Instance B
|
# Instance B
|
||||||
bun run start gateway -c ~/.nanobot-b/config.json
|
bun run nanobot gateway -c ~/.nanobot-b/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Each instance needs its own config file. Set a different `agent.workspacePath` per instance to keep memory, sessions, and cron jobs isolated:
|
Each instance needs its own config file. Set a different `agent.workspacePath` per instance to keep memory, sessions, and cron jobs isolated:
|
||||||
@@ -252,10 +250,10 @@ Each instance needs its own config file. Set a different `agent.workspacePath` p
|
|||||||
To run a local CLI session against a specific instance:
|
To run a local CLI session against a specific instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run start agent -c ~/.nanobot-a/config.json -m "Hello"
|
bun run nanobot agent -c ~/.nanobot-a/config.json -m "Hello"
|
||||||
|
|
||||||
# Temporarily override the workspace for a one-off run
|
# Temporarily override the workspace for a one-off run
|
||||||
bun run start agent -c ~/.nanobot-a/config.json -w /tmp/scratch
|
bun run nanobot agent -c ~/.nanobot-a/config.json -w /tmp/scratch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Linux service (systemd)
|
## Linux service (systemd)
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ Docs directory created with 4 files (PRD.md, Architecture.md, API.md, Discoverie
|
|||||||
- **lint**: all `${err}` in template literals → `${String(err)}`; `String(args['key'] ?? '')` → `strArg(args, 'key')` helper; unused `onProgress` param → `_onProgress`; WebSocket `onerror` `err` type is `Event` → use `err.type`
|
- **lint**: all `${err}` in template literals → `${String(err)}`; `String(args['key'] ?? '')` → `strArg(args, 'key')` helper; unused `onProgress` param → `_onProgress`; WebSocket `onerror` `err` type is `Event` → use `err.type`
|
||||||
|
|
||||||
## Work Queue (next steps)
|
## Work Queue (next steps)
|
||||||
1. [ ] Runtime smoke test: `bun run start --help`
|
1. [x] Create workspace helper module (src/workspace.ts) with ensureWorkspace() and syncTemplates()
|
||||||
2. [ ] Test with a real Mattermost config (optional — user can do this)
|
2. [x] Create onboard command (src/cli/onboard.ts) with path argument and directory-not-empty guard
|
||||||
3. [ ] Write sample `~/.nanobot/config.json` in README or docs
|
3. [x] Update src/cli/commands.ts to use ensureWorkspace() instead of inline mkdirSync
|
||||||
|
4. [x] Typecheck and lint pass (0 errors)
|
||||||
|
5. [x] Runtime smoke test: `bun run nanobot --help`
|
||||||
|
6. [x] Test onboard command: `bun run nanobot onboard [path]`
|
||||||
|
7. [ ] Test with a real Mattermost config (optional — user can do this)
|
||||||
|
|
||||||
## Key Decisions Made
|
## Key Decisions Made
|
||||||
- Mattermost channel uses raw WebSocket + fetch (no mattermostdriver, no SSL hack)
|
- Mattermost channel uses raw WebSocket + fetch (no mattermostdriver, no SSL hack)
|
||||||
|
|||||||
@@ -39,10 +39,19 @@
|
|||||||
### 🔄 In Progress
|
### 🔄 In Progress
|
||||||
- Nothing
|
- Nothing
|
||||||
|
|
||||||
|
### ✅ Done
|
||||||
|
- Created src/workspace.ts with ensureWorkspace(), syncTemplates(), checkWorkspaceEmpty()
|
||||||
|
- Created src/cli/onboard.ts command with path argument
|
||||||
|
- Updated src/cli/commands.ts to use ensureWorkspace() helper
|
||||||
|
- Typecheck: 0 errors
|
||||||
|
- Lint: 0 warnings
|
||||||
|
|
||||||
|
### 🔄 In Progress
|
||||||
|
- Testing onboard command
|
||||||
|
|
||||||
### ⏳ Pending
|
### ⏳ Pending
|
||||||
- Runtime smoke test: `bun run start --help`
|
- Runtime smoke test: `bun run nanobot --help`
|
||||||
- Integration test with a real Mattermost server
|
- Integration test with a real Mattermost server
|
||||||
- Sample `~/.nanobot/config.json` documentation
|
|
||||||
|
|
||||||
## Known Issues / Risks
|
## Known Issues / Risks
|
||||||
- `ollama-ai-provider` v1.2.0 returns `LanguageModelV1` (not V2/V3 as expected by AI SDK v6) — cast used at call site. Works at runtime.
|
- `ollama-ai-provider` v1.2.0 returns `LanguageModelV1` (not V2/V3 as expected by AI SDK v6) — cast used at call site. Works at runtime.
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ Inbound and outbound messages are passed through a typed `AsyncQueue<T>`. The qu
|
|||||||
## Logging Pattern
|
## Logging Pattern
|
||||||
Use `console.error` / `console.warn` / `console.info` / `console.debug` — no external logger. Color via `picocolors` in CLI output only.
|
Use `console.error` / `console.warn` / `console.info` / `console.debug` — no external logger. Color via `picocolors` in CLI output only.
|
||||||
|
|
||||||
|
## CLI Command Pattern
|
||||||
|
Each command is in its own file with a registration function:
|
||||||
|
```ts
|
||||||
|
// src/cli/agent.ts
|
||||||
|
export function agentCommand(program: Command, config: Config, workspace: string): void {
|
||||||
|
program.command('agent')
|
||||||
|
.description('...')
|
||||||
|
.option('-m, --message <text>', 'Single message to process')
|
||||||
|
.action(async (opts) => { /* ... */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/cli/commands.ts (bootstrap)
|
||||||
|
export function createCli(): Command {
|
||||||
|
const program = new Command('nanobot')...
|
||||||
|
const config = loadConfig(opts.config);
|
||||||
|
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
||||||
|
gatewayCommand(program, config, workspace);
|
||||||
|
agentCommand(program, config, workspace);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## File Layout
|
## File Layout
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
@@ -67,8 +89,12 @@ src/
|
|||||||
tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts
|
tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts
|
||||||
channels/
|
channels/
|
||||||
base.ts + mattermost.ts + manager.ts
|
base.ts + mattermost.ts + manager.ts
|
||||||
cli/commands.ts
|
cli/
|
||||||
index.ts
|
types.ts # CommandHandler type
|
||||||
|
commands.ts # Bootstrap - loads config, registers commands
|
||||||
|
agent.ts # agentCommand() - interactive/single-shot mode
|
||||||
|
gateway.ts # gatewayCommand() - full runtime with Mattermost
|
||||||
|
index.ts
|
||||||
templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md)
|
templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md)
|
||||||
skills/ (copied from Python repo)
|
skills/ (copied from Python repo)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run index.ts",
|
"nanobot": "bun run index.ts",
|
||||||
"dev": "bun --watch run index.ts",
|
"dev": "bun --watch run index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"fmt": "oxfmt --check",
|
"fmt": "oxfmt --check",
|
||||||
|
|||||||
@@ -364,13 +364,13 @@ export class AgentLoop {
|
|||||||
if (response.content) await onProgress(response.content);
|
if (response.content) await onProgress(response.content);
|
||||||
const hint = response.toolCalls
|
const hint = response.toolCalls
|
||||||
.map((tc) => {
|
.map((tc) => {
|
||||||
|
let display = ''
|
||||||
|
|
||||||
const firstVal = Object.values(tc.arguments)[0];
|
const firstVal = Object.values(tc.arguments)[0];
|
||||||
const display =
|
if (typeof firstVal === 'string') {
|
||||||
typeof firstVal === 'string'
|
display = `"${firstVal.slice(0, 40) + (firstVal.length > 40 ? '…' : '')}"`
|
||||||
? firstVal.length > 40
|
}
|
||||||
? `"${firstVal.slice(0, 40)}…"`
|
|
||||||
: `"${firstVal}"`
|
|
||||||
: '';
|
|
||||||
return `${tc.name}(${display})`;
|
return `${tc.name}(${display})`;
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|||||||
88
src/cli/agent.ts
Normal file
88
src/cli/agent.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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 { makeProvider } from '../provider/index.ts';
|
||||||
|
import type { Config } from '../config/types.ts';
|
||||||
|
|
||||||
|
export function agentCommand(program: Command, config: Config, workspace: string): void {
|
||||||
|
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('-M, --model <model>', 'Model override')
|
||||||
|
.action(async (opts: { config?: string; message?: string; model?: string }) => {
|
||||||
|
console.info(pc.magenta(`workspace path: ${workspace}`));
|
||||||
|
|
||||||
|
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,27 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import { Command } from 'commander';
|
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 { loadConfig, resolveWorkspacePath } from '../config/loader.ts';
|
||||||
import { CronService } from '../cron/service.ts';
|
import { agentCommand } from './agent.ts';
|
||||||
import { HeartbeatService } from '../heartbeat/service.ts';
|
import { gatewayCommand } from './gateway.ts';
|
||||||
import { makeProvider } from '../provider/index.ts';
|
import { onboardCommand } from './onboard.ts';
|
||||||
|
import { ensureWorkspace } from './utils.ts';
|
||||||
|
|
||||||
export function createCli(): Command {
|
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');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Register onboard command first (doesn't need config/workspace)
|
||||||
// gateway — full runtime: Mattermost + cron + heartbeat
|
onboardCommand(program);
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
program
|
// load config and get workspace
|
||||||
.command('gateway')
|
const globalOpts = program.opts();
|
||||||
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
|
const config = loadConfig(globalOpts.config);
|
||||||
.option('-c, --config <path>', 'Path to config.json')
|
|
||||||
.action(async (opts: { config?: string }) => {
|
|
||||||
const config = loadConfig(opts.config);
|
|
||||||
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
const workspace = resolveWorkspacePath(config.agent.workspacePath);
|
||||||
mkdirSync(workspace, { recursive: true });
|
|
||||||
|
|
||||||
const provider = makeProvider(config.providers, config.agent.model, config.agent.maxTokens, config.agent.temperature);
|
ensureWorkspace(workspace);
|
||||||
const bus = new MessageBus();
|
|
||||||
const channelManager = new ChannelManager(bus);
|
|
||||||
|
|
||||||
// Cron service
|
gatewayCommand(program, config, workspace);
|
||||||
const cronService = new CronService(workspace, async (job) => {
|
agentCommand(program, config, workspace);
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/cli/gateway.ts
Normal file
104
src/cli/gateway.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { ChannelManager } from '../channels/manager.ts';
|
||||||
|
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 { CronService } from '../cron/service.ts';
|
||||||
|
import { HeartbeatService } from '../heartbeat/service.ts';
|
||||||
|
import { makeProvider } from '../provider/index.ts';
|
||||||
|
|
||||||
|
import type { Config } from '../config/types.ts';
|
||||||
|
|
||||||
|
export function gatewayCommand(program: Command, config: Config, workspace: string): void {
|
||||||
|
program
|
||||||
|
.command('gateway')
|
||||||
|
.option('-c, --config <path>', 'Path to config.json')
|
||||||
|
.description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.')
|
||||||
|
.action(async (_opts: { config?: string }) => {
|
||||||
|
console.info(pc.magenta(`workspace path: ${workspace}`));
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
});
|
||||||
|
}
|
||||||
52
src/cli/onboard.ts
Normal file
52
src/cli/onboard.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { ConfigSchema, type Config } from '../config/types.ts';
|
||||||
|
import { ensureWorkspace, resolvePath, checkWorkspaceEmpty, syncTemplates } from './utils.ts';
|
||||||
|
|
||||||
|
export function onboardCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('onboard [path]')
|
||||||
|
.description('Initialize a new nanobot workspace with config and templates')
|
||||||
|
.action(async (rawPath?: string) => {
|
||||||
|
try {
|
||||||
|
const targetPath = resolvePath(rawPath ?? '~/.nanobot');
|
||||||
|
const configPath = join(targetPath, 'config.json');
|
||||||
|
|
||||||
|
console.info(pc.blue('Initializing nanobot workspace...'));
|
||||||
|
console.info(pc.dim(`Target path: ${targetPath}`));
|
||||||
|
|
||||||
|
// Check if directory exists and is not empty
|
||||||
|
checkWorkspaceEmpty(targetPath);
|
||||||
|
|
||||||
|
// Create workspace directory
|
||||||
|
ensureWorkspace(targetPath);
|
||||||
|
console.info(pc.green('✓ Created workspace directory'));
|
||||||
|
|
||||||
|
// Write default config
|
||||||
|
const defaultConfig: Config = ConfigSchema.parse({});
|
||||||
|
writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
|
||||||
|
console.info(pc.green('✓ Created config.json'));
|
||||||
|
|
||||||
|
// Sync templates
|
||||||
|
const createdFiles = syncTemplates(targetPath);
|
||||||
|
for (const file of createdFiles) {
|
||||||
|
console.info(pc.dim(` Created ${file}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info();
|
||||||
|
console.info(pc.green('nanobot workspace initialized successfully!'));
|
||||||
|
console.info();
|
||||||
|
console.info(pc.bold('Next steps:'));
|
||||||
|
console.info(` 1. Edit ${pc.cyan(configPath)} to add your API keys`);
|
||||||
|
console.info(` 2. Customize ${pc.cyan(join(targetPath, 'USER.md'))} with your preferences`);
|
||||||
|
console.info(` 3. Start chatting: ${pc.cyan('bun run nanobot agent')}`);
|
||||||
|
console.info();
|
||||||
|
console.info(pc.dim('For Mattermost integration, configure the channels.mattermost section in config.json'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(pc.red(String(err)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
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;
|
||||||
82
src/cli/utils.ts
Normal file
82
src/cli/utils.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
|
||||||
|
export function resolvePath(raw: string): string {
|
||||||
|
if (raw.startsWith('~/') || raw === '~') {
|
||||||
|
return resolve(homedir(), raw.slice(2));
|
||||||
|
}
|
||||||
|
return resolve(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureWorkspace(rawPath: string): string {
|
||||||
|
const path = resolvePath(rawPath);
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
mkdirSync(path, { recursive: true });
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTemplates(workspacePath: string): string[] {
|
||||||
|
// Get project root relative to this file
|
||||||
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
|
const srcDir = dirname(currentFile);
|
||||||
|
const projectRoot = resolve(srcDir, '..');
|
||||||
|
const templatesDir = resolve(projectRoot, 'templates');
|
||||||
|
|
||||||
|
if (!existsSync(templatesDir)) {
|
||||||
|
throw new Error(`Templates directory not found at ${templatesDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
function copyTemplate(src: string, dest: string) {
|
||||||
|
if (existsSync(dest)) return;
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
const content = readFileSync(src, 'utf8');
|
||||||
|
writeFileSync(dest, content, 'utf8');
|
||||||
|
created.push(dest.slice(workspacePath.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDir(srcDir: string, destDir: string) {
|
||||||
|
if (!existsSync(srcDir)) return;
|
||||||
|
const entries = readdirSync(srcDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = join(srcDir, entry.name);
|
||||||
|
const destPath = join(destDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDir(srcPath, destPath);
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
copyTemplate(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDir(templatesDir, workspacePath);
|
||||||
|
|
||||||
|
// Create empty HISTORY.md
|
||||||
|
const historyPath = join(workspacePath, 'memory', 'HISTORY.md');
|
||||||
|
if (!existsSync(historyPath)) {
|
||||||
|
mkdirSync(dirname(historyPath), { recursive: true });
|
||||||
|
writeFileSync(historyPath, '# Conversation History\n\n', 'utf8');
|
||||||
|
created.push('memory/HISTORY.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create skills directory
|
||||||
|
const skillsPath = join(workspacePath, 'skills');
|
||||||
|
if (!existsSync(skillsPath)) {
|
||||||
|
mkdirSync(skillsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkWorkspaceEmpty(path: string): void {
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
const entries = readdirSync(path);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
throw new Error(pc.red(`Directory not empty: ${path}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,17 +146,14 @@ export class LLMProvider {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let toolChoice: 'required' | 'none' | 'auto' = 'auto'
|
||||||
|
if (opts.toolChoice === 'required' || opts.toolChoice === 'none') toolChoice = opts.toolChoice
|
||||||
const result = await generateText({
|
const result = await generateText({
|
||||||
model,
|
model,
|
||||||
messages: opts.messages as ModelMessage[],
|
messages: opts.messages as ModelMessage[],
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tools type is complex
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tools type is complex
|
||||||
tools: aiTools as any,
|
tools: aiTools as any,
|
||||||
toolChoice:
|
toolChoice,
|
||||||
opts.toolChoice === 'required'
|
|
||||||
? 'required'
|
|
||||||
: opts.toolChoice === 'none'
|
|
||||||
? 'none'
|
|
||||||
: 'auto',
|
|
||||||
maxOutputTokens: maxTokens,
|
maxOutputTokens: maxTokens,
|
||||||
temperature,
|
temperature,
|
||||||
stopWhen: stepCountIs(1),
|
stopWhen: stepCountIs(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user