@iletai/nzb 1.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Burke Holland
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # NZB
2
+
3
+ AI orchestrator powered by [Copilot SDK](https://github.com/github/copilot-sdk) — control multiple Copilot CLI sessions from Telegram or a local terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ curl -fsSL https://raw.githubusercontent.com/iletai/AI-Agent-Assistant/main/install.sh | bash
9
+ ```
10
+
11
+ Or install directly with npm:
12
+
13
+ ```bash
14
+ npm install -g nzb
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ### 1. Run setup
20
+
21
+ ```bash
22
+ nzb setup
23
+ ```
24
+
25
+ This creates `~/.nzb/` and walks you through configuration (Telegram bot token, etc.). Telegram is optional — you can use NZB with just the terminal UI.
26
+
27
+ ### 2. Make sure Copilot CLI is authenticated
28
+
29
+ ```bash
30
+ copilot login
31
+ ```
32
+
33
+ ### 3. Start NZB
34
+
35
+ ```bash
36
+ nzb start
37
+ ```
38
+
39
+ ### 4. Connect via terminal
40
+
41
+ In a separate terminal:
42
+
43
+ ```bash
44
+ nzb tui
45
+ ```
46
+
47
+ ### 5. Talk to NZB
48
+
49
+ From Telegram or the TUI, just send natural language:
50
+
51
+ - "Start working on the auth bug in ~/dev/myapp"
52
+ - "What sessions are running?"
53
+ - "Check on the api-tests session"
54
+ - "Kill the auth-fix session"
55
+ - "What's the capital of France?"
56
+
57
+ ## Commands
58
+
59
+ | Command | Description |
60
+ |---------|-------------|
61
+ | `nzb start` | Start the NZB daemon |
62
+ | `nzb tui` | Connect to the daemon via terminal |
63
+ | `nzb setup` | Interactive first-run configuration |
64
+ | `nzb update` | Check for and install updates |
65
+ | `nzb help` | Show available commands |
66
+
67
+ ### Flags
68
+
69
+ | Flag | Description |
70
+ |------|-------------|
71
+ | `--self-edit` | Allow NZB to modify his own source code (use with `nzb start`) |
72
+
73
+ ### TUI commands
74
+
75
+ | Command | Description |
76
+ |---------|-------------|
77
+ | `/model [name]` | Show or switch the current model |
78
+ | `/memory` | Show stored memories |
79
+ | `/skills` | List installed skills |
80
+ | `/workers` | List active worker sessions |
81
+ | `/copy` | Copy last response to clipboard |
82
+ | `/status` | Daemon health check |
83
+ | `/restart` | Restart the daemon |
84
+ | `/cancel` | Cancel the current in-flight message |
85
+ | `/clear` | Clear the screen |
86
+ | `/help` | Show help |
87
+ | `/quit` | Exit the TUI |
88
+ | `Escape` | Cancel a running response |
89
+
90
+ ## How it Works
91
+
92
+ NZB runs a persistent **orchestrator Copilot session** — an always-on AI brain that receives your messages and decides how to handle them. For coding tasks, it spawns **worker Copilot sessions** in specific directories. For simple questions, it answers directly.
93
+
94
+ You can talk to NZB from:
95
+
96
+ - **Telegram** — remote access from your phone (authenticated by user ID)
97
+ - **TUI** — local terminal client (no auth needed)
98
+
99
+ ## Architecture
100
+
101
+ ```
102
+ Telegram ──→ NZB Daemon ←── TUI
103
+
104
+ Orchestrator Session (Copilot SDK)
105
+
106
+ ┌─────────┼─────────┐
107
+ Worker 1 Worker 2 Worker N
108
+ ```
109
+
110
+ - **Daemon** (`nzb start`) — persistent service running Copilot SDK + Telegram bot + HTTP API
111
+ - **TUI** (`nzb tui`) — lightweight terminal client connecting to the daemon
112
+ - **Orchestrator** — long-running Copilot session with custom tools for session management
113
+ - **Workers** — child Copilot sessions for specific coding tasks
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ # Clone and install
119
+ git clone https://github.com/iletai/AI-Agent-Assistant.git
120
+ cd AI-Agent-Assistant
121
+ npm install
122
+
123
+ # Watch mode
124
+ npm run dev
125
+
126
+ # Build TypeScript
127
+ npm run build
128
+ ```
129
+
130
+ This project uses TypeScript, Zod for schema validation, and a simple file-based SQLite database for state management. The Copilot SDK is used to create and manage AI sessions, and the Telegram Bot API is used for remote messaging. Reference the source code for implementation details!
131
+
132
+ Thankful for <https://github.com/burkeholland/max> which inspired this project.
@@ -0,0 +1,212 @@
1
+ import { randomBytes } from "crypto";
2
+ import express from "express";
3
+ import { existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { config, persistModel } from "../config.js";
5
+ import { cancelCurrentMessage, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
6
+ import { listSkills, removeSkill } from "../copilot/skills.js";
7
+ import { restartDaemon } from "../daemon.js";
8
+ import { API_TOKEN_PATH, ensureNZBHome } from "../paths.js";
9
+ import { searchMemories } from "../store/db.js";
10
+ import { sendPhoto } from "../telegram/bot.js";
11
+ // Ensure token file exists (generate on first run)
12
+ let apiToken = null;
13
+ try {
14
+ if (existsSync(API_TOKEN_PATH)) {
15
+ apiToken = readFileSync(API_TOKEN_PATH, "utf-8").trim();
16
+ }
17
+ else {
18
+ ensureNZBHome();
19
+ apiToken = randomBytes(32).toString("hex");
20
+ writeFileSync(API_TOKEN_PATH, apiToken, { mode: 0o600 });
21
+ }
22
+ }
23
+ catch (err) {
24
+ console.error(`[auth] Failed to load/generate API token: ${err}`);
25
+ process.exit(1);
26
+ }
27
+ const app = express();
28
+ app.use(express.json());
29
+ // Bearer token authentication middleware (skip /status health check)
30
+ app.use((req, res, next) => {
31
+ if (!apiToken || req.path === "/status" || req.path === "/send-photo")
32
+ return next();
33
+ const auth = req.headers.authorization;
34
+ if (!auth || auth !== `Bearer ${apiToken}`) {
35
+ res.status(401).json({ error: "Unauthorized" });
36
+ return;
37
+ }
38
+ next();
39
+ });
40
+ // Active SSE connections
41
+ const sseClients = new Map();
42
+ let connectionCounter = 0;
43
+ // Health check
44
+ app.get("/status", (_req, res) => {
45
+ res.json({
46
+ status: "ok",
47
+ workers: Array.from(getWorkers().values()).map((w) => ({
48
+ name: w.name,
49
+ workingDir: w.workingDir,
50
+ status: w.status,
51
+ })),
52
+ });
53
+ });
54
+ // List worker sessions
55
+ app.get("/sessions", (_req, res) => {
56
+ const workers = Array.from(getWorkers().values()).map((w) => ({
57
+ name: w.name,
58
+ workingDir: w.workingDir,
59
+ status: w.status,
60
+ lastOutput: w.lastOutput?.slice(0, 500),
61
+ }));
62
+ res.json(workers);
63
+ });
64
+ // SSE stream for real-time responses
65
+ app.get("/stream", (req, res) => {
66
+ const connectionId = `tui-${++connectionCounter}`;
67
+ res.writeHead(200, {
68
+ "Content-Type": "text/event-stream",
69
+ "Cache-Control": "no-cache",
70
+ Connection: "keep-alive",
71
+ });
72
+ res.write(`data: ${JSON.stringify({ type: "connected", connectionId })}\n\n`);
73
+ sseClients.set(connectionId, res);
74
+ // Heartbeat to keep connection alive
75
+ const heartbeat = setInterval(() => {
76
+ res.write(`:ping\n\n`);
77
+ }, 20_000);
78
+ req.on("close", () => {
79
+ clearInterval(heartbeat);
80
+ sseClients.delete(connectionId);
81
+ });
82
+ });
83
+ // Send a message to the orchestrator
84
+ app.post("/message", (req, res) => {
85
+ const { prompt, connectionId } = req.body;
86
+ if (!prompt || typeof prompt !== "string") {
87
+ res.status(400).json({ error: "Missing 'prompt' in request body" });
88
+ return;
89
+ }
90
+ if (!connectionId || !sseClients.has(connectionId)) {
91
+ res.status(400).json({ error: "Missing or invalid 'connectionId'. Connect to /stream first." });
92
+ return;
93
+ }
94
+ sendToOrchestrator(prompt, { type: "tui", connectionId }, (text, done) => {
95
+ const sseRes = sseClients.get(connectionId);
96
+ if (sseRes) {
97
+ sseRes.write(`data: ${JSON.stringify({ type: done ? "message" : "delta", content: text })}\n\n`);
98
+ }
99
+ });
100
+ res.json({ status: "queued" });
101
+ });
102
+ // Cancel the current in-flight message
103
+ app.post("/cancel", async (_req, res) => {
104
+ const cancelled = await cancelCurrentMessage();
105
+ // Notify all SSE clients that the message was cancelled
106
+ for (const [, sseRes] of sseClients) {
107
+ sseRes.write(`data: ${JSON.stringify({ type: "cancelled" })}\n\n`);
108
+ }
109
+ res.json({ status: "ok", cancelled });
110
+ });
111
+ // Get or switch model
112
+ app.get("/model", (_req, res) => {
113
+ res.json({ model: config.copilotModel });
114
+ });
115
+ app.post("/model", async (req, res) => {
116
+ const { model } = req.body;
117
+ if (!model || typeof model !== "string") {
118
+ res.status(400).json({ error: "Missing 'model' in request body" });
119
+ return;
120
+ }
121
+ // Validate against available models before persisting
122
+ try {
123
+ const { getClient } = await import("../copilot/client.js");
124
+ const client = await getClient();
125
+ const models = await client.listModels();
126
+ const match = models.find((m) => m.id === model);
127
+ if (!match) {
128
+ const suggestions = models
129
+ .filter((m) => m.id.includes(model) || m.id.toLowerCase().includes(model.toLowerCase()))
130
+ .map((m) => m.id);
131
+ const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
132
+ res.status(400).json({ error: `Model '${model}' not found.${hint}` });
133
+ return;
134
+ }
135
+ }
136
+ catch {
137
+ // If we can't validate (client not ready), allow the switch — it'll fail on next message if wrong
138
+ }
139
+ const previous = config.copilotModel;
140
+ config.copilotModel = model;
141
+ persistModel(model);
142
+ res.json({ previous, current: model });
143
+ });
144
+ // List memories
145
+ app.get("/memory", (_req, res) => {
146
+ const memories = searchMemories(undefined, undefined, 100);
147
+ res.json(memories);
148
+ });
149
+ // List skills
150
+ app.get("/skills", (_req, res) => {
151
+ const skills = listSkills();
152
+ res.json(skills);
153
+ });
154
+ // Remove a local skill
155
+ app.delete("/skills/:slug", (req, res) => {
156
+ const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
157
+ const result = removeSkill(slug);
158
+ if (!result.ok) {
159
+ res.status(400).json({ error: result.message });
160
+ }
161
+ else {
162
+ res.json({ ok: true, message: result.message });
163
+ }
164
+ });
165
+ // Restart daemon
166
+ app.post("/restart", (_req, res) => {
167
+ res.json({ status: "restarting" });
168
+ setTimeout(() => {
169
+ restartDaemon().catch((err) => {
170
+ console.error("[nzb] Restart failed:", err);
171
+ });
172
+ }, 500);
173
+ });
174
+ // Send a photo to Telegram (protected by bearer token auth middleware)
175
+ app.post("/send-photo", async (req, res) => {
176
+ const { photo, caption } = req.body;
177
+ if (!photo || typeof photo !== "string") {
178
+ res.status(400).json({ error: "Missing 'photo' (file path or URL) in request body" });
179
+ return;
180
+ }
181
+ try {
182
+ await sendPhoto(photo, caption);
183
+ res.json({ status: "sent" });
184
+ }
185
+ catch (err) {
186
+ const msg = err instanceof Error ? err.message : String(err);
187
+ res.status(500).json({ error: msg });
188
+ }
189
+ });
190
+ export function startApiServer() {
191
+ return new Promise((resolve, reject) => {
192
+ const server = app.listen(config.apiPort, "127.0.0.1", () => {
193
+ console.log(`[nzb] HTTP API listening on http://127.0.0.1:${config.apiPort}`);
194
+ resolve();
195
+ });
196
+ server.on("error", (err) => {
197
+ if (err.code === "EADDRINUSE") {
198
+ reject(new Error(`Port ${config.apiPort} is already in use. Is another NZB instance running?`));
199
+ }
200
+ else {
201
+ reject(err);
202
+ }
203
+ });
204
+ });
205
+ }
206
+ /** Broadcast a proactive message to all connected SSE clients (for background task completions). */
207
+ export function broadcastToSSE(text) {
208
+ for (const [, res] of sseClients) {
209
+ res.write(`data: ${JSON.stringify({ type: "message", content: text })}\n\n`);
210
+ }
211
+ }
212
+ //# sourceMappingURL=server.js.map
package/dist/cli.js ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ function getVersion() {
7
+ try {
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
9
+ return pkg.version || "0.0.0";
10
+ }
11
+ catch {
12
+ return "0.0.0";
13
+ }
14
+ }
15
+ function printHelp() {
16
+ const version = getVersion();
17
+ console.log(`
18
+ nzb v${version} — AI orchestrator powered by Copilot SDK
19
+
20
+ Usage:
21
+ nzb <command>
22
+
23
+ Commands:
24
+ start Start the NZB daemon (Telegram bot + HTTP API)
25
+ tui Connect to the daemon via terminal UI
26
+ setup Interactive first-run configuration
27
+ update Check for updates and install the latest version
28
+ help Show this help message
29
+
30
+ Flags (start):
31
+ --self-edit Allow NZB to modify his own source code (off by default)
32
+
33
+ Examples:
34
+ nzb start Start the daemon
35
+ nzb start --self-edit Start with self-edit enabled
36
+ nzb tui Open the terminal client
37
+ nzb setup Configure Telegram token and settings
38
+ `.trim());
39
+ }
40
+ const args = process.argv.slice(2);
41
+ const command = args[0] || "help";
42
+ switch (command) {
43
+ case "start": {
44
+ // Parse flags for start command
45
+ const startFlags = args.slice(1);
46
+ if (startFlags.includes("--self-edit")) {
47
+ process.env.NZB_SELF_EDIT = "1";
48
+ }
49
+ await import("./daemon.js");
50
+ break;
51
+ }
52
+ case "tui":
53
+ await import("./tui/index.js");
54
+ break;
55
+ case "setup":
56
+ await import("./setup.js");
57
+ break;
58
+ case "update": {
59
+ const { checkForUpdate, performUpdate } = await import("./update.js");
60
+ const check = await checkForUpdate();
61
+ if (!check.checkSucceeded) {
62
+ console.error("Warning: Could not reach the npm registry. Check your network and try again.");
63
+ process.exit(1);
64
+ }
65
+ if (!check.updateAvailable) {
66
+ console.log(`nzb v${check.current} is already the latest version.`);
67
+ break;
68
+ }
69
+ console.log(`Update available: v${check.current} → v${check.latest}`);
70
+ console.log("Installing...");
71
+ const result = await performUpdate();
72
+ if (result.ok) {
73
+ console.log(`Updated to v${check.latest}`);
74
+ }
75
+ else {
76
+ console.error(`Update failed: ${result.output}`);
77
+ process.exit(1);
78
+ }
79
+ break;
80
+ }
81
+ case "help":
82
+ case "--help":
83
+ case "-h":
84
+ printHelp();
85
+ break;
86
+ case "--version":
87
+ case "-v":
88
+ console.log(getVersion());
89
+ break;
90
+ default:
91
+ console.error(`Unknown command: ${command}\n`);
92
+ printHelp();
93
+ process.exit(1);
94
+ }
95
+ //# sourceMappingURL=cli.js.map
package/dist/config.js ADDED
@@ -0,0 +1,72 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { readFileSync, writeFileSync } from "fs";
3
+ import { z } from "zod";
4
+ import { ENV_PATH, ensureNZBHome } from "./paths.js";
5
+ // Load from ~/.nzb/.env, fall back to cwd .env for dev
6
+ loadEnv({ path: ENV_PATH });
7
+ loadEnv(); // also check cwd for backwards compat
8
+ const configSchema = z.object({
9
+ TELEGRAM_BOT_TOKEN: z.string().min(1).optional(),
10
+ AUTHORIZED_USER_ID: z.string().min(1).optional(),
11
+ API_PORT: z.string().optional(),
12
+ COPILOT_MODEL: z.string().optional(),
13
+ WORKER_TIMEOUT: z.string().optional(),
14
+ });
15
+ const raw = configSchema.parse(process.env);
16
+ const parsedUserId = raw.AUTHORIZED_USER_ID ? parseInt(raw.AUTHORIZED_USER_ID, 10) : undefined;
17
+ const parsedPort = parseInt(raw.API_PORT || "7777", 10);
18
+ if (parsedUserId !== undefined && (Number.isNaN(parsedUserId) || parsedUserId <= 0)) {
19
+ throw new Error(`AUTHORIZED_USER_ID must be a positive integer, got: "${raw.AUTHORIZED_USER_ID}"`);
20
+ }
21
+ if (Number.isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
22
+ throw new Error(`API_PORT must be 1-65535, got: "${raw.API_PORT}"`);
23
+ }
24
+ const DEFAULT_WORKER_TIMEOUT_MS = 600_000; // 10 minutes
25
+ const parsedWorkerTimeout = raw.WORKER_TIMEOUT ? Number(raw.WORKER_TIMEOUT) : DEFAULT_WORKER_TIMEOUT_MS;
26
+ if (!Number.isInteger(parsedWorkerTimeout) || parsedWorkerTimeout <= 0) {
27
+ throw new Error(`WORKER_TIMEOUT must be a positive integer (ms), got: "${raw.WORKER_TIMEOUT}"`);
28
+ }
29
+ export const DEFAULT_MODEL = "claude-sonnet-4.6";
30
+ let _copilotModel = raw.COPILOT_MODEL || DEFAULT_MODEL;
31
+ export const config = {
32
+ telegramBotToken: raw.TELEGRAM_BOT_TOKEN,
33
+ authorizedUserId: parsedUserId,
34
+ apiPort: parsedPort,
35
+ workerTimeoutMs: parsedWorkerTimeout,
36
+ get copilotModel() {
37
+ return _copilotModel;
38
+ },
39
+ set copilotModel(model) {
40
+ _copilotModel = model;
41
+ },
42
+ get telegramEnabled() {
43
+ return !!this.telegramBotToken && this.authorizedUserId !== undefined;
44
+ },
45
+ get selfEditEnabled() {
46
+ return process.env.NZB_SELF_EDIT === "1";
47
+ },
48
+ };
49
+ /** Persist the current model choice to ~/.nzb/.env */
50
+ export function persistModel(model) {
51
+ ensureNZBHome();
52
+ try {
53
+ const content = readFileSync(ENV_PATH, "utf-8");
54
+ const lines = content.split("\n");
55
+ let found = false;
56
+ const updated = lines.map((line) => {
57
+ if (line.startsWith("COPILOT_MODEL=")) {
58
+ found = true;
59
+ return `COPILOT_MODEL=${model}`;
60
+ }
61
+ return line;
62
+ });
63
+ if (!found)
64
+ updated.push(`COPILOT_MODEL=${model}`);
65
+ writeFileSync(ENV_PATH, updated.join("\n"));
66
+ }
67
+ catch {
68
+ // File doesn't exist — create it
69
+ writeFileSync(ENV_PATH, `COPILOT_MODEL=${model}\n`);
70
+ }
71
+ }
72
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,32 @@
1
+ import { CopilotClient } from "@github/copilot-sdk";
2
+ let client;
3
+ export async function getClient() {
4
+ if (!client) {
5
+ client = new CopilotClient({
6
+ autoStart: true,
7
+ autoRestart: true,
8
+ });
9
+ await client.start();
10
+ }
11
+ return client;
12
+ }
13
+ /** Tear down the existing client and create a fresh one. */
14
+ export async function resetClient() {
15
+ if (client) {
16
+ try {
17
+ await client.stop();
18
+ }
19
+ catch {
20
+ /* best-effort */
21
+ }
22
+ client = undefined;
23
+ }
24
+ return getClient();
25
+ }
26
+ export async function stopClient() {
27
+ if (client) {
28
+ await client.stop();
29
+ client = undefined;
30
+ }
31
+ }
32
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ /**
5
+ * Load MCP server configs from ~/.copilot/mcp-config.json.
6
+ * Returns an empty record if the file doesn't exist or is invalid.
7
+ */
8
+ export function loadMcpConfig() {
9
+ const configPath = join(homedir(), ".copilot", "mcp-config.json");
10
+ try {
11
+ const raw = readFileSync(configPath, "utf-8");
12
+ const parsed = JSON.parse(raw);
13
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
14
+ return parsed.mcpServers;
15
+ }
16
+ return {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ //# sourceMappingURL=mcp-config.js.map