From 32dda34af49f8d70c188860f87961fe947bafa43 Mon Sep 17 00:00:00 2001 From: tomermesser Date: Sun, 8 Mar 2026 16:38:03 +0200 Subject: [PATCH 001/124] status-icon-01 --- .claude/skills/add-statusbar/SKILL.md | 140 ++++++++++++++++++ .../add-statusbar/add/src/statusbar.swift | 139 +++++++++++++++++ .claude/skills/add-statusbar/manifest.yaml | 10 ++ 3 files changed, 289 insertions(+) create mode 100644 .claude/skills/add-statusbar/SKILL.md create mode 100644 .claude/skills/add-statusbar/add/src/statusbar.swift create mode 100644 .claude/skills/add-statusbar/manifest.yaml diff --git a/.claude/skills/add-statusbar/SKILL.md b/.claude/skills/add-statusbar/SKILL.md new file mode 100644 index 0000000..c0f343c --- /dev/null +++ b/.claude/skills/add-statusbar/SKILL.md @@ -0,0 +1,140 @@ +--- +name: add-statusbar +description: Add a macOS menu bar status indicator for NanoClaw. Shows a ⚡ icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only. +--- + +# Add macOS Menu Bar Status Indicator + +Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar. + +**macOS only.** Requires Xcode Command Line Tools (`swiftc`). + +## Phase 1: Pre-flight + +### Check platform + +If not on macOS, stop and tell the user: + +> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools). + +### Check for swiftc + +```bash +which swiftc +``` + +If not found, tell the user: + +> Xcode Command Line Tools are required. Install them by running: +> +> ```bash +> xcode-select --install +> ``` +> +> Then re-run `/add-statusbar`. + +### Check if already installed + +```bash +launchctl list | grep com.nanoclaw.statusbar +``` + +If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 4 (Verify). + +## Phase 2: Apply Code Changes + +### Initialize skills system (if needed) + +If `.nanoclaw/` directory doesn't exist yet: + +```bash +npx tsx scripts/apply-skill.ts --init +``` + +### Apply the skill + +```bash +npx tsx scripts/apply-skill.ts .claude/skills/add-statusbar +``` + +This copies `src/statusbar.swift` into the project and records the application in `.nanoclaw/state.yaml`. + +## Phase 3: Compile and Install + +### Compile the Swift binary + +```bash +swiftc -O -o dist/statusbar src/statusbar.swift +``` + +This produces a small (~55KB) native binary at `dist/statusbar`. + +### Create the launchd plist + +Determine the absolute project root: + +```bash +pwd +``` + +Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values for `{PROJECT_ROOT}` and `{HOME}`: + +```xml + + + + + Label + com.nanoclaw.statusbar + ProgramArguments + + {PROJECT_ROOT}/dist/statusbar + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + HOME + {HOME} + + StandardOutPath + {PROJECT_ROOT}/logs/statusbar.log + StandardErrorPath + {PROJECT_ROOT}/logs/statusbar.error.log + + +``` + +### Load the service + +```bash +launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist +``` + +## Phase 4: Verify + +```bash +launchctl list | grep com.nanoclaw.statusbar +``` + +The first column should show a PID (not `-`). + +Tell the user: + +> The ⚡ icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service. +> +> - **Green dot** — NanoClaw is running +> - **Red dot** — NanoClaw is stopped +> +> Use **Restart** after making code changes, and **View Logs** to open the log file directly. + +## Removal + +```bash +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist +rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist +rm dist/statusbar +rm src/statusbar.swift +``` diff --git a/.claude/skills/add-statusbar/add/src/statusbar.swift b/.claude/skills/add-statusbar/add/src/statusbar.swift new file mode 100644 index 0000000..6fff79a --- /dev/null +++ b/.claude/skills/add-statusbar/add/src/statusbar.swift @@ -0,0 +1,139 @@ +import AppKit + +class StatusBarController: NSObject { + private var statusItem: NSStatusItem! + private var isRunning = false + private var timer: Timer? + + private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist" + + override init() { + super.init() + setupStatusItem() + isRunning = checkRunning() + updateMenu() + // Poll every 5 seconds to reflect external state changes + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + guard let self else { return } + let current = self.checkRunning() + if current != self.isRunning { + self.isRunning = current + self.updateMenu() + } + } + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + if let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "NanoClaw") { + image.isTemplate = true + button.image = image + } else { + button.title = "⚡" + } + button.toolTip = "NanoClaw" + } + } + + private func checkRunning() -> Bool { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["list", "com.nanoclaw"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + guard (try? task.run()) != nil else { return false } + task.waitUntilExit() + if task.terminationStatus != 0 { return false } + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + // launchctl list output: "PID\tExitCode\tLabel" — "-" means not running + let pid = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t").first ?? "-" + return pid != "-" + } + + private func updateMenu() { + let menu = NSMenu() + + // Status row with colored dot + let statusItem = NSMenuItem() + let dot = "● " + let dotColor: NSColor = isRunning ? .systemGreen : .systemRed + let attr = NSMutableAttributedString(string: dot, attributes: [.foregroundColor: dotColor]) + let label = isRunning ? "NanoClaw is running" : "NanoClaw is stopped" + attr.append(NSAttributedString(string: label, attributes: [.foregroundColor: NSColor.labelColor])) + statusItem.attributedTitle = attr + statusItem.isEnabled = false + menu.addItem(statusItem) + + menu.addItem(NSMenuItem.separator()) + + if isRunning { + let stop = NSMenuItem(title: "Stop", action: #selector(stopService), keyEquivalent: "") + stop.target = self + menu.addItem(stop) + + let restart = NSMenuItem(title: "Restart", action: #selector(restartService), keyEquivalent: "r") + restart.target = self + menu.addItem(restart) + } else { + let start = NSMenuItem(title: "Start", action: #selector(startService), keyEquivalent: "") + start.target = self + menu.addItem(start) + } + + menu.addItem(NSMenuItem.separator()) + + let logs = NSMenuItem(title: "View Logs", action: #selector(viewLogs), keyEquivalent: "") + logs.target = self + menu.addItem(logs) + + self.statusItem.menu = menu + } + + @objc private func startService() { + run("/bin/launchctl", ["load", plistPath]) + refresh(after: 2) + } + + @objc private func stopService() { + run("/bin/launchctl", ["unload", plistPath]) + refresh(after: 2) + } + + @objc private func restartService() { + let uid = getuid() + run("/bin/launchctl", ["kickstart", "-k", "gui/\(uid)/com.nanoclaw"]) + refresh(after: 3) + } + + @objc private func viewLogs() { + let logPath = "\(NSHomeDirectory())/Documents/Projects/nanoclaw/logs/nanoclaw.log" + NSWorkspace.shared.open(URL(fileURLWithPath: logPath)) + } + + private func refresh(after seconds: Double) { + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in + guard let self else { return } + self.isRunning = self.checkRunning() + self.updateMenu() + } + } + + @discardableResult + private func run(_ path: String, _ args: [String]) -> Int32 { + let task = Process() + task.launchPath = path + task.arguments = args + task.standardOutput = Pipe() + task.standardError = Pipe() + try? task.run() + task.waitUntilExit() + return task.terminationStatus + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let controller = StatusBarController() +app.run() diff --git a/.claude/skills/add-statusbar/manifest.yaml b/.claude/skills/add-statusbar/manifest.yaml new file mode 100644 index 0000000..0d7d720 --- /dev/null +++ b/.claude/skills/add-statusbar/manifest.yaml @@ -0,0 +1,10 @@ +skill: statusbar +version: 1.0.0 +description: "macOS menu bar status indicator — shows NanoClaw running state with start/stop/restart controls" +core_version: 0.1.0 +adds: + - src/statusbar.swift +modifies: [] +structured: {} +conflicts: [] +depends: [] From 83b91b3bf106d36e3260fe0eee35a159302d3fc8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 8 Mar 2026 22:43:37 +0200 Subject: [PATCH 002/124] skill/telegram: Telegram channel integration Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- package-lock.json | 113 ++++- package.json | 1 + src/channels/index.ts | 1 + src/channels/telegram.test.ts | 932 ++++++++++++++++++++++++++++++++++ src/channels/telegram.ts | 257 ++++++++++ 6 files changed, 1300 insertions(+), 6 deletions(-) create mode 100644 src/channels/telegram.test.ts create mode 100644 src/channels/telegram.ts diff --git a/.env.example b/.env.example index 8b13789..b90e6c9 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ - +TELEGRAM_BOT_TOKEN= diff --git a/package-lock.json b/package-lock.json index 4e6b681..6bc4160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "grammy": "^1.39.3", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -531,6 +532,12 @@ "node": ">=18" } }, + "node_modules/@grammyjs/types": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1109,6 +1116,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1258,6 +1277,23 @@ "node": "*" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1359,6 +1395,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1454,6 +1499,21 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/grammy": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.25.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1654,6 +1714,12 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1691,6 +1757,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1740,7 +1826,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2289,13 +2374,18 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2355,7 +2445,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2431,7 +2520,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2504,6 +2592,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2532,7 +2636,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index f4863e4..687b875 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "better-sqlite3": "^11.8.1", + "grammy": "^1.39.3", "cron-parser": "^5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", diff --git a/src/channels/index.ts b/src/channels/index.ts index 44f4f55..48356db 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -8,5 +8,6 @@ // slack // telegram +import './telegram.js'; // whatsapp diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts new file mode 100644 index 0000000..9a97223 --- /dev/null +++ b/src/channels/telegram.test.ts @@ -0,0 +1,932 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +// Mock registry (registerChannel runs at import time) +vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); + +// Mock env reader (used by the factory, not needed in unit tests) +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); + +// Mock config +vi.mock('../config.js', () => ({ + ASSISTANT_NAME: 'Andy', + TRIGGER_PATTERN: /^@Andy\b/i, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// --- Grammy mock --- + +type Handler = (...args: any[]) => any; + +const botRef = vi.hoisted(() => ({ current: null as any })); + +vi.mock('grammy', () => ({ + Bot: class MockBot { + token: string; + commandHandlers = new Map(); + filterHandlers = new Map(); + errorHandler: Handler | null = null; + + api = { + sendMessage: vi.fn().mockResolvedValue(undefined), + sendChatAction: vi.fn().mockResolvedValue(undefined), + }; + + constructor(token: string) { + this.token = token; + botRef.current = this; + } + + command(name: string, handler: Handler) { + this.commandHandlers.set(name, handler); + } + + on(filter: string, handler: Handler) { + const existing = this.filterHandlers.get(filter) || []; + existing.push(handler); + this.filterHandlers.set(filter, existing); + } + + catch(handler: Handler) { + this.errorHandler = handler; + } + + start(opts: { onStart: (botInfo: any) => void }) { + opts.onStart({ username: 'andy_ai_bot', id: 12345 }); + } + + stop() {} + }, +})); + +import { TelegramChannel, TelegramChannelOpts } from './telegram.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): TelegramChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function createTextCtx(overrides: { + chatId?: number; + chatType?: string; + chatTitle?: string; + text: string; + fromId?: number; + firstName?: string; + username?: string; + messageId?: number; + date?: number; + entities?: any[]; +}) { + const chatId = overrides.chatId ?? 100200300; + const chatType = overrides.chatType ?? 'group'; + return { + chat: { + id: chatId, + type: chatType, + title: overrides.chatTitle ?? 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: overrides.username ?? 'alice_user', + }, + message: { + text: overrides.text, + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + entities: overrides.entities ?? [], + }, + me: { username: 'andy_ai_bot' }, + reply: vi.fn(), + }; +} + +function createMediaCtx(overrides: { + chatId?: number; + chatType?: string; + fromId?: number; + firstName?: string; + date?: number; + messageId?: number; + caption?: string; + extra?: Record; +}) { + const chatId = overrides.chatId ?? 100200300; + return { + chat: { + id: chatId, + type: overrides.chatType ?? 'group', + title: 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: 'alice_user', + }, + message: { + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + caption: overrides.caption, + ...(overrides.extra || {}), + }, + me: { username: 'andy_ai_bot' }, + }; +} + +function currentBot() { + return botRef.current; +} + +async function triggerTextMessage(ctx: ReturnType) { + const handlers = currentBot().filterHandlers.get('message:text') || []; + for (const h of handlers) await h(ctx); +} + +async function triggerMediaMessage( + filter: string, + ctx: ReturnType, +) { + const handlers = currentBot().filterHandlers.get(filter) || []; + for (const h of handlers) await h(ctx); +} + +// --- Tests --- + +describe('TelegramChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when bot starts', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(channel.isConnected()).toBe(true); + }); + + it('registers command and message handlers on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().commandHandlers.has('chatid')).toBe(true); + expect(currentBot().commandHandlers.has('ping')).toBe(true); + expect(currentBot().filterHandlers.has('message:text')).toBe(true); + expect(currentBot().filterHandlers.has('message:photo')).toBe(true); + expect(currentBot().filterHandlers.has('message:video')).toBe(true); + expect(currentBot().filterHandlers.has('message:voice')).toBe(true); + expect(currentBot().filterHandlers.has('message:audio')).toBe(true); + expect(currentBot().filterHandlers.has('message:document')).toBe(true); + expect(currentBot().filterHandlers.has('message:sticker')).toBe(true); + expect(currentBot().filterHandlers.has('message:location')).toBe(true); + expect(currentBot().filterHandlers.has('message:contact')).toBe(true); + }); + + it('registers error handler on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().errorHandler).not.toBeNull(); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + expect(channel.isConnected()).toBe(true); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + + it('isConnected() returns false before connect', () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + expect(channel.isConnected()).toBe(false); + }); + }); + + // --- Text message handling --- + + describe('text message handling', () => { + it('delivers message for registered group', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hello everyone' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Test Group', + 'telegram', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + id: '1', + chat_jid: 'tg:100200300', + sender: '99001', + sender_name: 'Alice', + content: 'Hello everyone', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:999999', + expect.any(String), + 'Test Group', + 'telegram', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('skips command messages (starting with /)', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: '/start' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + expect(opts.onChatMetadata).not.toHaveBeenCalled(); + }); + + it('extracts sender name from first_name', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'Bob' }), + ); + }); + + it('falls back to username when first_name missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi' }); + ctx.from.first_name = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'alice_user' }), + ); + }); + + it('falls back to user ID when name and username missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', fromId: 42 }); + ctx.from.first_name = undefined as any; + ctx.from.username = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: '42' }), + ); + }); + + it('uses sender name as chat name for private chats', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Private', + folder: 'private', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'private', + firstName: 'Alice', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Alice', // Private chats use sender name + 'telegram', + false, + ); + }); + + it('uses chat title as name for group chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'supergroup', + chatTitle: 'Project Team', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Project Team', + 'telegram', + true, + ); + }); + + it('converts message.date to ISO timestamp', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z + const ctx = createTextCtx({ text: 'Hello', date: unixTime }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + timestamp: '2024-01-01T00:00:00.000Z', + }), + ); + }); + }); + + // --- @mention translation --- + + describe('@mention translation', () => { + it('translates @bot_username mention to trigger format', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@andy_ai_bot what time is it?', + entities: [{ type: 'mention', offset: 0, length: 12 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot what time is it?', + }), + ); + }); + + it('does not translate if message already matches trigger', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@Andy @andy_ai_bot hello', + entities: [{ type: 'mention', offset: 6, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Should NOT double-prepend — already starts with @Andy + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot hello', + }), + ); + }); + + it('does not translate mentions of other bots', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@some_other_bot hi', + entities: [{ type: 'mention', offset: 0, length: 15 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@some_other_bot hi', // No translation + }), + ); + }); + + it('handles mention in middle of message', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'hey @andy_ai_bot check this', + entities: [{ type: 'mention', offset: 4, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Bot is mentioned, message doesn't match trigger → prepend trigger + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy hey @andy_ai_bot check this', + }), + ); + }); + + it('handles message with no entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'plain message' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'plain message', + }), + ); + }); + + it('ignores non-mention entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'check https://example.com', + entities: [{ type: 'url', offset: 6, length: 19 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'check https://example.com', + }), + ); + }); + }); + + // --- Non-text messages --- + + describe('non-text messages', () => { + it('stores photo with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo]' }), + ); + }); + + it('stores photo with caption', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ caption: 'Look at this' }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo] Look at this' }), + ); + }); + + it('stores video with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:video', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Video]' }), + ); + }); + + it('stores voice message with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:voice', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Voice message]' }), + ); + }); + + it('stores audio with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:audio', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Audio]' }), + ); + }); + + it('stores document with filename', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { document: { file_name: 'report.pdf' } }, + }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: report.pdf]' }), + ); + }); + + it('stores document with fallback name when filename missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ extra: { document: {} } }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: file]' }), + ); + }); + + it('stores sticker with emoji', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { sticker: { emoji: '😂' } }, + }); + await triggerMediaMessage('message:sticker', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Sticker 😂]' }), + ); + }); + + it('stores location with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:location', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Location]' }), + ); + }); + + it('stores contact with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:contact', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Contact]' }), + ); + }); + + it('ignores non-text messages from unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ chatId: 999999 }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + }); + + // --- sendMessage --- + + describe('sendMessage', () => { + it('sends message via bot API', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:100200300', 'Hello'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '100200300', + 'Hello', + ); + }); + + it('strips tg: prefix from JID', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:-1001234567890', 'Group message'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '-1001234567890', + 'Group message', + ); + }); + + it('splits messages exceeding 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const longText = 'x'.repeat(5000); + await channel.sendMessage('tg:100200300', longText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 1, + '100200300', + 'x'.repeat(4096), + ); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 2, + '100200300', + 'x'.repeat(904), + ); + }); + + it('sends exactly one message at 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const exactText = 'y'.repeat(4096); + await channel.sendMessage('tg:100200300', exactText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('handles send failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendMessage.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw + await expect( + channel.sendMessage('tg:100200300', 'Will fail'), + ).resolves.toBeUndefined(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect — bot is null + await channel.sendMessage('tg:100200300', 'No bot'); + + // No error, no API call + }); + }); + + // --- ownsJid --- + + describe('ownsJid', () => { + it('owns tg: JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:123456')).toBe(true); + }); + + it('owns tg: JIDs with negative IDs (groups)', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:-1001234567890')).toBe(true); + }); + + it('does not own WhatsApp group JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(false); + }); + + it('does not own WhatsApp DM JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing action when isTyping is true', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', true); + + expect(currentBot().api.sendChatAction).toHaveBeenCalledWith( + '100200300', + 'typing', + ); + }); + + it('does nothing when isTyping is false', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', false); + + expect(currentBot().api.sendChatAction).not.toHaveBeenCalled(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect + await channel.setTyping('tg:100200300', true); + + // No error, no API call + }); + + it('handles typing indicator failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendChatAction.mockRejectedValueOnce( + new Error('Rate limited'), + ); + + await expect( + channel.setTyping('tg:100200300', true), + ).resolves.toBeUndefined(); + }); + }); + + // --- Bot commands --- + + describe('bot commands', () => { + it('/chatid replies with chat ID and metadata', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 100200300, type: 'group' as const }, + from: { first_name: 'Alice' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('tg:100200300'), + expect.objectContaining({ parse_mode: 'Markdown' }), + ); + }); + + it('/chatid shows chat type', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 555, type: 'private' as const }, + from: { first_name: 'Bob' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('private'), + expect.any(Object), + ); + }); + + it('/ping replies with bot status', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('ping')!; + const ctx = { reply: vi.fn() }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith('Andy is online.'); + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "telegram"', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.name).toBe('telegram'); + }); + }); +}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts new file mode 100644 index 0000000..4176f03 --- /dev/null +++ b/src/channels/telegram.ts @@ -0,0 +1,257 @@ +import { Bot } from 'grammy'; + +import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; +import { readEnvFile } from '../env.js'; +import { logger } from '../logger.js'; +import { registerChannel, ChannelOpts } from './registry.js'; +import { + Channel, + OnChatMetadata, + OnInboundMessage, + RegisteredGroup, +} from '../types.js'; + +export interface TelegramChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +export class TelegramChannel implements Channel { + name = 'telegram'; + + private bot: Bot | null = null; + private opts: TelegramChannelOpts; + private botToken: string; + + constructor(botToken: string, opts: TelegramChannelOpts) { + this.botToken = botToken; + this.opts = opts; + } + + async connect(): Promise { + this.bot = new Bot(this.botToken); + + // Command to get chat ID (useful for registration) + this.bot.command('chatid', (ctx) => { + const chatId = ctx.chat.id; + const chatType = ctx.chat.type; + const chatName = + chatType === 'private' + ? ctx.from?.first_name || 'Private' + : (ctx.chat as any).title || 'Unknown'; + + ctx.reply( + `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`, + { parse_mode: 'Markdown' }, + ); + }); + + // Command to check bot status + this.bot.command('ping', (ctx) => { + ctx.reply(`${ASSISTANT_NAME} is online.`); + }); + + this.bot.on('message:text', async (ctx) => { + // Skip commands + if (ctx.message.text.startsWith('/')) return; + + const chatJid = `tg:${ctx.chat.id}`; + let content = ctx.message.text; + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id.toString() || + 'Unknown'; + const sender = ctx.from?.id.toString() || ''; + const msgId = ctx.message.message_id.toString(); + + // Determine chat name + const chatName = + ctx.chat.type === 'private' + ? senderName + : (ctx.chat as any).title || chatJid; + + // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. + // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN + // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned. + const botUsername = ctx.me?.username?.toLowerCase(); + if (botUsername) { + const entities = ctx.message.entities || []; + const isBotMentioned = entities.some((entity) => { + if (entity.type === 'mention') { + const mentionText = content + .substring(entity.offset, entity.offset + entity.length) + .toLowerCase(); + return mentionText === `@${botUsername}`; + } + return false; + }); + if (isBotMentioned && !TRIGGER_PATTERN.test(content)) { + content = `@${ASSISTANT_NAME} ${content}`; + } + } + + // Store chat metadata for discovery + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); + + // Only deliver full message for registered groups + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + logger.debug( + { chatJid, chatName }, + 'Message from unregistered Telegram chat', + ); + return; + } + + // Deliver message — startMessageLoop() will pick it up + this.opts.onMessage(chatJid, { + id: msgId, + chat_jid: chatJid, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + }); + + logger.info( + { chatJid, chatName, sender: senderName }, + 'Telegram message stored', + ); + }); + + // Handle non-text messages with placeholders so the agent knows something was sent + const storeNonText = (ctx: any, placeholder: string) => { + const chatJid = `tg:${ctx.chat.id}`; + const group = this.opts.registeredGroups()[chatJid]; + if (!group) return; + + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id?.toString() || + 'Unknown'; + const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; + + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); + this.opts.onMessage(chatJid, { + id: ctx.message.message_id.toString(), + chat_jid: chatJid, + sender: ctx.from?.id?.toString() || '', + sender_name: senderName, + content: `${placeholder}${caption}`, + timestamp, + is_from_me: false, + }); + }; + + this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); + this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); + this.bot.on('message:voice', (ctx) => + storeNonText(ctx, '[Voice message]'), + ); + this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); + this.bot.on('message:document', (ctx) => { + const name = ctx.message.document?.file_name || 'file'; + storeNonText(ctx, `[Document: ${name}]`); + }); + this.bot.on('message:sticker', (ctx) => { + const emoji = ctx.message.sticker?.emoji || ''; + storeNonText(ctx, `[Sticker ${emoji}]`); + }); + this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]')); + this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]')); + + // Handle errors gracefully + this.bot.catch((err) => { + logger.error({ err: err.message }, 'Telegram bot error'); + }); + + // Start polling — returns a Promise that resolves when started + return new Promise((resolve) => { + this.bot!.start({ + onStart: (botInfo) => { + logger.info( + { username: botInfo.username, id: botInfo.id }, + 'Telegram bot connected', + ); + console.log(`\n Telegram bot: @${botInfo.username}`); + console.log( + ` Send /chatid to the bot to get a chat's registration ID\n`, + ); + resolve(); + }, + }); + }); + } + + async sendMessage(jid: string, text: string): Promise { + if (!this.bot) { + logger.warn('Telegram bot not initialized'); + return; + } + + try { + const numericId = jid.replace(/^tg:/, ''); + + // Telegram has a 4096 character limit per message — split if needed + const MAX_LENGTH = 4096; + if (text.length <= MAX_LENGTH) { + await this.bot.api.sendMessage(numericId, text); + } else { + for (let i = 0; i < text.length; i += MAX_LENGTH) { + await this.bot.api.sendMessage( + numericId, + text.slice(i, i + MAX_LENGTH), + ); + } + } + logger.info({ jid, length: text.length }, 'Telegram message sent'); + } catch (err) { + logger.error({ jid, err }, 'Failed to send Telegram message'); + } + } + + isConnected(): boolean { + return this.bot !== null; + } + + ownsJid(jid: string): boolean { + return jid.startsWith('tg:'); + } + + async disconnect(): Promise { + if (this.bot) { + this.bot.stop(); + this.bot = null; + logger.info('Telegram bot stopped'); + } + } + + async setTyping(jid: string, isTyping: boolean): Promise { + if (!this.bot || !isTyping) return; + try { + const numericId = jid.replace(/^tg:/, ''); + await this.bot.api.sendChatAction(numericId, 'typing'); + } catch (err) { + logger.debug({ jid, err }, 'Failed to send Telegram typing indicator'); + } + } +} + +registerChannel('telegram', (opts: ChannelOpts) => { + const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']); + const token = + process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || ''; + if (!token) { + logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set'); + return null; + } + return new TelegramChannel(token, opts); +}); From 5acab2c09d9d567cc748bea2bced75e7c5a3b40a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 9 Mar 2026 23:43:19 +0200 Subject: [PATCH 003/124] ci: add upstream sync and merge-forward workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/merge-forward-skills.yml | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 .github/workflows/merge-forward-skills.yml diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml new file mode 100644 index 0000000..5b6d7df --- /dev/null +++ b/.github/workflows/merge-forward-skills.yml @@ -0,0 +1,200 @@ +name: Sync upstream & merge-forward skill branches + +on: + # Triggered by upstream repo via repository_dispatch + repository_dispatch: + types: [upstream-main-updated] + # Fallback: run on schedule in case dispatch isn't configured + schedule: + - cron: '0 */6 * * *' # every 6 hours + # Also run when fork's main is pushed directly + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + sync-and-merge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync with upstream main + id: sync + run: | + # Add upstream remote + git remote add upstream https://github.com/qwibitai/nanoclaw.git + git fetch upstream main + + # Check if upstream has new commits + if git merge-base --is-ancestor upstream/main HEAD; then + echo "Already up to date with upstream main." + echo "synced=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Merge upstream main into fork's main + if ! git merge upstream/main --no-edit; then + echo "::error::Failed to merge upstream/main into fork main — conflicts detected" + git merge --abort + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Validate build + npm ci + if ! npm run build; then + echo "::error::Build failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! npm test 2>/dev/null; then + echo "::error::Tests failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git push origin main + echo "synced=true" >> "$GITHUB_OUTPUT" + + - name: Merge main into skill branches + id: merge + run: | + FAILED="" + SUCCEEDED="" + + # List all remote skill branches + SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) + + if [ -z "$SKILL_BRANCHES" ]; then + echo "No skill branches found." + exit 0 + fi + + for BRANCH in $SKILL_BRANCHES; do + SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') + echo "" + echo "=== Processing $BRANCH ===" + + git checkout -B "$BRANCH" "origin/$BRANCH" + + if ! git merge main --no-edit; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Check if there's anything new to push + if git diff --quiet "origin/$BRANCH"; then + echo "$BRANCH is already up to date with main." + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + continue + fi + + npm ci + + if ! npm run build; then + echo "::warning::Build failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + if ! npm test 2>/dev/null; then + echo "::warning::Tests failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + git push origin "$BRANCH" + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + echo "$BRANCH merged and pushed successfully." + done + + echo "" + echo "=== Results ===" + echo "Succeeded: $SUCCEEDED" + echo "Failed: $FAILED" + + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" + + - name: Open issue for upstream sync failure + if: steps.sync.outputs.sync_failed == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Upstream sync failed — merge conflict or build failure`, + body: [ + 'The automated sync with `qwibitai/nanoclaw` main failed.', + '', + 'This usually means upstream made changes that conflict with this fork\'s channel code.', + '', + 'To resolve manually:', + '```bash', + 'git fetch upstream main', + 'git merge upstream/main', + '# resolve conflicts', + 'npm run build && npm test', + 'git push', + '```', + ].join('\n'), + labels: ['upstream-sync'] + }); + + - name: Open issue for failed skill merges + if: steps.merge.outputs.failed != '' + uses: actions/github-script@v7 + with: + script: | + const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); + const body = [ + `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, + '', + ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), + '', + 'Please resolve manually:', + '```bash', + ...failed.map(s => [ + `git checkout skill/${s}`, + `git merge main`, + `# resolve conflicts, then: git push`, + '' + ]).flat(), + '```', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Merge-forward failed for ${failed.length} skill branch(es)`, + body, + labels: ['skill-maintenance'] + }); From d487faf55aecd872ef8082179b2603d7e9571e44 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 00:53:40 +0200 Subject: [PATCH 004/124] ci: rename sync workflow to fork-sync-skills.yml to avoid merge conflicts with core --- .github/workflows/fork-sync-skills.yml | 201 +++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/fork-sync-skills.yml diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml new file mode 100644 index 0000000..14e10a0 --- /dev/null +++ b/.github/workflows/fork-sync-skills.yml @@ -0,0 +1,201 @@ +name: Sync upstream & merge-forward skill branches + +on: + # Triggered by upstream repo via repository_dispatch + repository_dispatch: + types: [upstream-main-updated] + # Fallback: run on schedule in case dispatch isn't configured + schedule: + - cron: '0 */6 * * *' # every 6 hours + # Also run when fork's main is pushed directly + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + sync-and-merge: + if: github.repository_owner != 'qwibitai' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync with upstream main + id: sync + run: | + # Add upstream remote + git remote add upstream https://github.com/qwibitai/nanoclaw.git + git fetch upstream main + + # Check if upstream has new commits + if git merge-base --is-ancestor upstream/main HEAD; then + echo "Already up to date with upstream main." + echo "synced=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Merge upstream main into fork's main + if ! git merge upstream/main --no-edit; then + echo "::error::Failed to merge upstream/main into fork main — conflicts detected" + git merge --abort + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Validate build + npm ci + if ! npm run build; then + echo "::error::Build failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! npm test 2>/dev/null; then + echo "::error::Tests failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git push origin main + echo "synced=true" >> "$GITHUB_OUTPUT" + + - name: Merge main into skill branches + id: merge + run: | + FAILED="" + SUCCEEDED="" + + # List all remote skill branches + SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) + + if [ -z "$SKILL_BRANCHES" ]; then + echo "No skill branches found." + exit 0 + fi + + for BRANCH in $SKILL_BRANCHES; do + SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') + echo "" + echo "=== Processing $BRANCH ===" + + git checkout -B "$BRANCH" "origin/$BRANCH" + + if ! git merge main --no-edit; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Check if there's anything new to push + if git diff --quiet "origin/$BRANCH"; then + echo "$BRANCH is already up to date with main." + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + continue + fi + + npm ci + + if ! npm run build; then + echo "::warning::Build failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + if ! npm test 2>/dev/null; then + echo "::warning::Tests failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + git push origin "$BRANCH" + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + echo "$BRANCH merged and pushed successfully." + done + + echo "" + echo "=== Results ===" + echo "Succeeded: $SUCCEEDED" + echo "Failed: $FAILED" + + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" + + - name: Open issue for upstream sync failure + if: steps.sync.outputs.sync_failed == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Upstream sync failed — merge conflict or build failure`, + body: [ + 'The automated sync with `qwibitai/nanoclaw` main failed.', + '', + 'This usually means upstream made changes that conflict with this fork\'s channel code.', + '', + 'To resolve manually:', + '```bash', + 'git fetch upstream main', + 'git merge upstream/main', + '# resolve conflicts', + 'npm run build && npm test', + 'git push', + '```', + ].join('\n'), + labels: ['upstream-sync'] + }); + + - name: Open issue for failed skill merges + if: steps.merge.outputs.failed != '' + uses: actions/github-script@v7 + with: + script: | + const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); + const body = [ + `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, + '', + ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), + '', + 'Please resolve manually:', + '```bash', + ...failed.map(s => [ + `git checkout skill/${s}`, + `git merge main`, + `# resolve conflicts, then: git push`, + '' + ]).flat(), + '```', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Merge-forward failed for ${failed.length} skill branch(es)`, + body, + labels: ['skill-maintenance'] + }); From b913a37c2142a3abafef64b74c46d9f77b4b5a1d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 00:53:51 +0200 Subject: [PATCH 005/124] ci: remove old merge-forward-skills.yml (replaced by fork-sync-skills.yml) --- .github/workflows/merge-forward-skills.yml | 200 --------------------- 1 file changed, 200 deletions(-) delete mode 100644 .github/workflows/merge-forward-skills.yml diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml deleted file mode 100644 index 5b6d7df..0000000 --- a/.github/workflows/merge-forward-skills.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: Sync upstream & merge-forward skill branches - -on: - # Triggered by upstream repo via repository_dispatch - repository_dispatch: - types: [upstream-main-updated] - # Fallback: run on schedule in case dispatch isn't configured - schedule: - - cron: '0 */6 * * *' # every 6 hours - # Also run when fork's main is pushed directly - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - issues: write - -jobs: - sync-and-merge: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Sync with upstream main - id: sync - run: | - # Add upstream remote - git remote add upstream https://github.com/qwibitai/nanoclaw.git - git fetch upstream main - - # Check if upstream has new commits - if git merge-base --is-ancestor upstream/main HEAD; then - echo "Already up to date with upstream main." - echo "synced=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Merge upstream main into fork's main - if ! git merge upstream/main --no-edit; then - echo "::error::Failed to merge upstream/main into fork main — conflicts detected" - git merge --abort - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Validate build - npm ci - if ! npm run build; then - echo "::error::Build failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! npm test 2>/dev/null; then - echo "::error::Tests failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git push origin main - echo "synced=true" >> "$GITHUB_OUTPUT" - - - name: Merge main into skill branches - id: merge - run: | - FAILED="" - SUCCEEDED="" - - # List all remote skill branches - SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) - - if [ -z "$SKILL_BRANCHES" ]; then - echo "No skill branches found." - exit 0 - fi - - for BRANCH in $SKILL_BRANCHES; do - SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') - echo "" - echo "=== Processing $BRANCH ===" - - git checkout -B "$BRANCH" "origin/$BRANCH" - - if ! git merge main --no-edit; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue - fi - - # Check if there's anything new to push - if git diff --quiet "origin/$BRANCH"; then - echo "$BRANCH is already up to date with main." - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - continue - fi - - npm ci - - if ! npm run build; then - echo "::warning::Build failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - if ! npm test 2>/dev/null; then - echo "::warning::Tests failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - git push origin "$BRANCH" - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - echo "$BRANCH merged and pushed successfully." - done - - echo "" - echo "=== Results ===" - echo "Succeeded: $SUCCEEDED" - echo "Failed: $FAILED" - - echo "failed=$FAILED" >> "$GITHUB_OUTPUT" - echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" - - - name: Open issue for upstream sync failure - if: steps.sync.outputs.sync_failed == 'true' - uses: actions/github-script@v7 - with: - script: | - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Upstream sync failed — merge conflict or build failure`, - body: [ - 'The automated sync with `qwibitai/nanoclaw` main failed.', - '', - 'This usually means upstream made changes that conflict with this fork\'s channel code.', - '', - 'To resolve manually:', - '```bash', - 'git fetch upstream main', - 'git merge upstream/main', - '# resolve conflicts', - 'npm run build && npm test', - 'git push', - '```', - ].join('\n'), - labels: ['upstream-sync'] - }); - - - name: Open issue for failed skill merges - if: steps.merge.outputs.failed != '' - uses: actions/github-script@v7 - with: - script: | - const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); - const body = [ - `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, - '', - ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), - '', - 'Please resolve manually:', - '```bash', - ...failed.map(s => [ - `git checkout skill/${s}`, - `git merge main`, - `# resolve conflicts, then: git push`, - '' - ]).flat(), - '```', - ].join('\n'); - - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Merge-forward failed for ${failed.length} skill branch(es)`, - body, - labels: ['skill-maintenance'] - }); From 9a4fb61f6e037d3f82f0ec700ec6a888964321d9 Mon Sep 17 00:00:00 2001 From: James Schindler Date: Tue, 10 Mar 2026 11:58:00 -0400 Subject: [PATCH 006/124] feat: add Markdown formatting for outbound messages Wrap outbound sendMessage calls with parse_mode: 'Markdown' so that Claude's natural formatting (*bold*, _italic_, `code`, etc.) renders correctly in Telegram instead of showing raw asterisks and underscores. Falls back to plain text if Telegram rejects the Markdown formatting. --- src/channels/telegram.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 4176f03..c7d19e5 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,4 +1,4 @@ -import { Bot } from 'grammy'; +import { Api, Bot } from 'grammy'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; import { readEnvFile } from '../env.js'; @@ -17,6 +17,29 @@ export interface TelegramChannelOpts { registeredGroups: () => Record; } +/** + * Send a message with Telegram Markdown parse mode, falling back to plain text. + * Claude's output naturally matches Telegram's Markdown v1 format: + * *bold*, _italic_, `code`, ```code blocks```, [links](url) + */ +async function sendTelegramMessage( + api: { sendMessage: Api['sendMessage'] }, + chatId: string | number, + text: string, + options: { message_thread_id?: number } = {}, +): Promise { + try { + await api.sendMessage(chatId, text, { + ...options, + parse_mode: 'Markdown', + }); + } catch (err) { + // Fallback: send as plain text if Markdown parsing fails + logger.debug({ err }, 'Markdown send failed, falling back to plain text'); + await api.sendMessage(chatId, text, options); + } +} + export class TelegramChannel implements Channel { name = 'telegram'; @@ -203,10 +226,11 @@ export class TelegramChannel implements Channel { // Telegram has a 4096 character limit per message — split if needed const MAX_LENGTH = 4096; if (text.length <= MAX_LENGTH) { - await this.bot.api.sendMessage(numericId, text); + await sendTelegramMessage(this.bot.api, numericId, text); } else { for (let i = 0; i < text.length; i += MAX_LENGTH) { - await this.bot.api.sendMessage( + await sendTelegramMessage( + this.bot.api, numericId, text.slice(i, i + MAX_LENGTH), ); From 107f9742a9ba0e13cfb334f751b7cb26aafb703e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:00:36 +0200 Subject: [PATCH 007/124] fix: update sync condition to check repo name, not owner --- .github/workflows/fork-sync-skills.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 14e10a0..e1c81c7 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -18,7 +18,7 @@ permissions: jobs: sync-and-merge: - if: github.repository_owner != 'qwibitai' + if: github.repository != 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -166,7 +166,8 @@ jobs: 'npm run build && npm test', 'git push', '```', - ].join('\n'), + ].join(' +'), labels: ['upstream-sync'] }); @@ -190,7 +191,8 @@ jobs: '' ]).flat(), '```', - ].join('\n'); + ].join(' +'); await github.rest.issues.create({ owner: context.repo.owner, From 15ed3cf2a65f1a14b5e712a95a5b7400f8b7fea0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:10:37 +0200 Subject: [PATCH 008/124] fix: repair escaped newlines in fork-sync workflow --- .github/workflows/fork-sync-skills.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index e1c81c7..259dec8 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -4,7 +4,7 @@ on: # Triggered by upstream repo via repository_dispatch repository_dispatch: types: [upstream-main-updated] - # Fallback: run on schedule in case dispatch isn't configured + # Fallback: run on a schedule in case dispatch isn't configured schedule: - cron: '0 */6 * * *' # every 6 hours # Also run when fork's main is pushed directly @@ -166,8 +166,7 @@ jobs: 'npm run build && npm test', 'git push', '```', - ].join(' -'), + ].join('\n'), labels: ['upstream-sync'] }); @@ -191,8 +190,7 @@ jobs: '' ]).flat(), '```', - ].join(' -'); + ].join('\n'); await github.rest.issues.create({ owner: context.repo.owner, @@ -200,4 +198,4 @@ jobs: title: `Merge-forward failed for ${failed.length} skill branch(es)`, body, labels: ['skill-maintenance'] - }); + }); \ No newline at end of file From 018deca3ef027ecd6d1e4bb571b64d2aa8f636a4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:16:02 +0200 Subject: [PATCH 009/124] fix: use GitHub App token for fork-sync (workflows permission needed) --- .github/workflows/fork-sync-skills.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 259dec8..dced479 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -21,10 +21,16 @@ jobs: if: github.repository != 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - uses: actions/setup-node@v4 with: From 51ad9499797836c234d6100b6d350893501adfd0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:28:12 +0200 Subject: [PATCH 010/124] fix: re-fetch before skill branch merges to avoid stale refs --- .github/workflows/fork-sync-skills.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index dced479..273bfc7 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -89,6 +89,9 @@ jobs: - name: Merge main into skill branches id: merge run: | + # Re-fetch to pick up any changes pushed since job start + git fetch origin + FAILED="" SUCCEEDED="" From 7061480ac008a46390017bcc1d6dfcc8250e9ca9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:43:00 +0200 Subject: [PATCH 011/124] fix: add concurrency group to prevent parallel fork-sync races --- .github/workflows/fork-sync-skills.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 273bfc7..8d25ee2 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -16,6 +16,10 @@ permissions: contents: write issues: write +concurrency: + group: fork-sync + cancel-in-progress: true + jobs: sync-and-merge: if: github.repository != 'qwibitai/nanoclaw' From 272cbcf18f204df78ccf82dc45b0d27ba4341693 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 11 Mar 2026 12:06:28 +0200 Subject: [PATCH 012/124] fix: update sendMessage test expectations for Markdown parse_mode The sendTelegramMessage helper now passes { parse_mode: 'Markdown' } to bot.api.sendMessage, but three tests still expected only two args. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts index 9a97223..564a20e 100644 --- a/src/channels/telegram.test.ts +++ b/src/channels/telegram.test.ts @@ -710,6 +710,7 @@ describe('TelegramChannel', () => { expect(currentBot().api.sendMessage).toHaveBeenCalledWith( '100200300', 'Hello', + { parse_mode: 'Markdown' }, ); }); @@ -723,6 +724,7 @@ describe('TelegramChannel', () => { expect(currentBot().api.sendMessage).toHaveBeenCalledWith( '-1001234567890', 'Group message', + { parse_mode: 'Markdown' }, ); }); @@ -739,11 +741,13 @@ describe('TelegramChannel', () => { 1, '100200300', 'x'.repeat(4096), + { parse_mode: 'Markdown' }, ); expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( 2, '100200300', 'x'.repeat(904), + { parse_mode: 'Markdown' }, ); }); From 845da49fa39fa27b4378b607e6c8f58e11f7af5c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 11 Mar 2026 12:08:52 +0200 Subject: [PATCH 013/124] fix: prettier formatting for telegram.ts Pre-existing formatting issue that causes CI format check to fail. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c7d19e5..7b95924 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -117,8 +117,15 @@ export class TelegramChannel implements Channel { } // Store chat metadata for discovery - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); + const isGroup = + ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata( + chatJid, + timestamp, + chatName, + 'telegram', + isGroup, + ); // Only deliver full message for registered groups const group = this.opts.registeredGroups()[chatJid]; @@ -161,8 +168,15 @@ export class TelegramChannel implements Channel { 'Unknown'; const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); + const isGroup = + ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata( + chatJid, + timestamp, + undefined, + 'telegram', + isGroup, + ); this.opts.onMessage(chatJid, { id: ctx.message.message_id.toString(), chat_jid: chatJid, @@ -176,9 +190,7 @@ export class TelegramChannel implements Channel { this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); - this.bot.on('message:voice', (ctx) => - storeNonText(ctx, '[Voice message]'), - ); + this.bot.on('message:voice', (ctx) => storeNonText(ctx, '[Voice message]')); this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); this.bot.on('message:document', (ctx) => { const name = ctx.message.document?.file_name || 'file'; From cb9fba8472b629bee7f8ae1a64140eda17d27b36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 10:09:48 +0000 Subject: [PATCH 014/124] chore: bump version to 1.2.13 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e7969b..34b2aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 560200d..0e915d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 2dedd15ec71a2b52553f334eb4a0c2bd8398f0ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 10:09:50 +0000 Subject: [PATCH 015/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?0.9k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 182aaa2..993856e 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 37.5k tokens, 19% of context window + + 40.9k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 37.5k + + 40.9k From d000acc6873bc611563b312d6c95ea4a4ecb5622 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 11 Mar 2026 22:46:57 +0200 Subject: [PATCH 016/124] fix: use https.globalAgent in grammY Bot to support sandbox proxy grammY creates its own https.Agent internally, bypassing any global proxy. In Docker Sandbox, NanoClaw sets https.globalAgent to a proxy agent at startup. This tells grammY to use it instead. On non-sandbox setups it's a no-op. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 7b95924..0b990d2 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,3 +1,4 @@ +import https from 'https'; import { Api, Bot } from 'grammy'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; @@ -53,7 +54,11 @@ export class TelegramChannel implements Channel { } async connect(): Promise { - this.bot = new Bot(this.botToken); + this.bot = new Bot(this.botToken, { + client: { + baseFetchConfig: { agent: https.globalAgent, compress: true }, + }, + }); // Command to get chat ID (useful for registration) this.bot.command('chatid', (ctx) => { From f210fd5049a704f0404a0ebcc68e89f96d23fe9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 20:52:39 +0000 Subject: [PATCH 017/124] chore: bump version to 1.2.14 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34b2aa8..b720403 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 0e915d3..a0d1e63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From d81f8e122113f00e65cd9391801267bbcc56dfbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 20:52:43 +0000 Subject: [PATCH 018/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?1.0k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 993856e..be808ed 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.9k tokens, 20% of context window + + 41.0k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.9k + + 41.0k From d1975462c49b79e8023fc24a1206b65078abdb71 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 14 Mar 2026 15:16:33 +0200 Subject: [PATCH 019/124] chore: bump claude-agent-sdk to ^0.2.76 Co-Authored-By: Claude Opus 4.6 --- container/agent-runner/package-lock.json | 8 ++++---- container/agent-runner/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 89cee2c..9ae119b 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,9 +19,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.68", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz", - "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==", + "version": "0.2.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", + "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index bf13328..42a994e 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" From 54a55affa403fceac6e87c1247f0b23580515cf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 13:16:49 +0000 Subject: [PATCH 020/124] chore: bump version to 1.2.15 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b720403..0db62f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index a0d1e63..c77580e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 662e81fc9e9858be5135078585ce643e97ef14fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 13:17:37 +0000 Subject: [PATCH 021/124] chore: bump version to 1.2.16 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0db62f4..deffe16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index c77580e..4db8178 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From cb20038956fdd2cc89c778614e72b62cff8b3ff7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 14 Mar 2026 17:01:23 +0200 Subject: [PATCH 022/124] fix: only skip /chatid and /ping, let other / messages through Previously all messages starting with / were silently dropped. This prevented NanoClaw-level commands like /remote-control from reaching the onMessage callback. Now only Telegram bot commands (/chatid, /ping) are skipped; everything else flows through as a regular message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/telegram.test.ts | 21 +++++++++++++++++---- src/channels/telegram.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts index 564a20e..538c87b 100644 --- a/src/channels/telegram.test.ts +++ b/src/channels/telegram.test.ts @@ -295,16 +295,29 @@ describe('TelegramChannel', () => { expect(opts.onMessage).not.toHaveBeenCalled(); }); - it('skips command messages (starting with /)', async () => { + it('skips bot commands (/chatid, /ping) but passes other / messages through', async () => { const opts = createTestOpts(); const channel = new TelegramChannel('test-token', opts); await channel.connect(); - const ctx = createTextCtx({ text: '/start' }); - await triggerTextMessage(ctx); - + // Bot commands should be skipped + const ctx1 = createTextCtx({ text: '/chatid' }); + await triggerTextMessage(ctx1); expect(opts.onMessage).not.toHaveBeenCalled(); expect(opts.onChatMetadata).not.toHaveBeenCalled(); + + const ctx2 = createTextCtx({ text: '/ping' }); + await triggerTextMessage(ctx2); + expect(opts.onMessage).not.toHaveBeenCalled(); + + // Non-bot /commands should flow through + const ctx3 = createTextCtx({ text: '/remote-control' }); + await triggerTextMessage(ctx3); + expect(opts.onMessage).toHaveBeenCalledTimes(1); + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '/remote-control' }), + ); }); it('extracts sender name from first_name', async () => { diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 0b990d2..effca6e 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -80,9 +80,15 @@ export class TelegramChannel implements Channel { ctx.reply(`${ASSISTANT_NAME} is online.`); }); + // Telegram bot commands handled above — skip them in the general handler + // so they don't also get stored as messages. All other /commands flow through. + const TELEGRAM_BOT_COMMANDS = new Set(['chatid', 'ping']); + this.bot.on('message:text', async (ctx) => { - // Skip commands - if (ctx.message.text.startsWith('/')) return; + if (ctx.message.text.startsWith('/')) { + const cmd = ctx.message.text.slice(1).split(/[\s@]/)[0].toLowerCase(); + if (TELEGRAM_BOT_COMMANDS.has(cmd)) return; + } const chatJid = `tg:${ctx.chat.id}`; let content = ctx.message.text; From 3d649c386ebef00f7024e196d6f39ea69c638aa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 15:08:11 +0000 Subject: [PATCH 023/124] chore: bump version to 1.2.17 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index deffe16..5f7f779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 4db8178..5c6a114 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From c984e6f13da4267a64f9fd300b05bc236cf86216 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 15:08:11 +0000 Subject: [PATCH 024/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?1.1k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index be808ed..1b06f80 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.0k tokens, 20% of context window + + 41.1k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.0k + + 41.1k From 675acffeb1656b43a4470b01495bd88dfd8bf78f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 12:57:40 +0200 Subject: [PATCH 025/124] feat: add script field to ScheduledTask type and database layer Adds optional `script` field to the ScheduledTask interface, with a migration for existing DBs and full support in createTask/updateTask. Co-Authored-By: Claude Sonnet 4.6 --- src/db.ts | 20 +++++++++++++++++--- src/types.ts | 1 + 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/db.ts b/src/db.ts index 0896f41..36e3edc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -93,6 +93,15 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Add script column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, + ); + } catch { + /* column already exists */ + } + // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( @@ -368,14 +377,15 @@ export function createTask( ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, task.prompt, + task.script || null, task.schedule_type, task.schedule_value, task.context_mode || 'isolated', @@ -410,7 +420,7 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' > >, ): void { @@ -421,6 +431,10 @@ export function updateTask( fields.push('prompt = ?'); values.push(updates.prompt); } + if (updates.script !== undefined) { + fields.push('script = ?'); + values.push(updates.script || null); + } if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); diff --git a/src/types.ts b/src/types.ts index acbb08a..bcef463 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export interface ScheduledTask { group_folder: string; chat_jid: string; prompt: string; + script?: string | null; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; context_mode: 'group' | 'isolated'; From a516cc5cfea2eceb14cd694df3b39d0356835ea9 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:28:36 +0200 Subject: [PATCH 026/124] feat: add script parameter to MCP task tools Add optional `script` field to schedule_task and update_task MCP tools, allowing agents to attach a pre-flight bash script that controls whether the task agent is woken up. Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/ipc-mcp-stdio.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 9de0138..5b03478 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), + script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'), }, async (args) => { // Validate schedule_value before writing IPC @@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): type: 'schedule_task', taskId, prompt: args.prompt, + script: args.script || undefined, schedule_type: args.schedule_type, schedule_value: args.schedule_value, context_mode: args.context_mode || 'group', @@ -255,6 +257,7 @@ server.tool( prompt: z.string().optional().describe('New prompt for the task'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), + script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'), }, async (args) => { // Validate schedule_value if provided @@ -288,6 +291,7 @@ server.tool( timestamp: new Date().toISOString(), }; if (args.prompt !== undefined) data.prompt = args.prompt; + if (args.script !== undefined) data.script = args.script; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; From 0f283cbdd33a594665812ac4997e9ed0f736caf1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:31:12 +0200 Subject: [PATCH 027/124] feat: pass script through IPC task processing Thread the optional `script` field through the IPC layer so it is persisted when an agent calls schedule_task, and updated when an agent calls update_task (empty string clears the script). Co-Authored-By: Claude Sonnet 4.6 --- src/ipc.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ipc.ts b/src/ipc.ts index 48efeb5..043b07a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -162,6 +162,7 @@ export async function processTaskIpc( schedule_type?: string; schedule_value?: string; context_mode?: string; + script?: string; groupFolder?: string; chatJid?: string; targetJid?: string; @@ -260,6 +261,7 @@ export async function processTaskIpc( group_folder: targetFolder, chat_jid: targetJid, prompt: data.prompt, + script: data.script || null, schedule_type: scheduleType, schedule_value: data.schedule_value, context_mode: contextMode, @@ -352,6 +354,7 @@ export async function processTaskIpc( const updates: Parameters[1] = {}; if (data.prompt !== undefined) updates.prompt = data.prompt; + if (data.script !== undefined) updates.script = data.script || null; if (data.schedule_type !== undefined) updates.schedule_type = data.schedule_type as | 'cron' From eb65121938210a1b8cb4d5909843d7be518c2fa1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:14 +0200 Subject: [PATCH 028/124] feat: add script to ContainerInput and task snapshot Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/index.ts | 1 + src/container-runner.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 96cb4a4..2cd34c9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -27,6 +27,7 @@ interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } interface ContainerOutput { diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..469fe11 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -41,6 +41,7 @@ export interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } export interface ContainerOutput { @@ -649,6 +650,7 @@ export function writeTasksSnapshot( id: string; groupFolder: string; prompt: string; + script?: string | null; schedule_type: string; schedule_value: string; status: string; From 42d098c3c1f5835f8cd77dd0205f74b687239b25 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:28 +0200 Subject: [PATCH 029/124] feat: pass script from task scheduler to container Co-Authored-By: Claude Sonnet 4.6 --- src/task-scheduler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index d0abd2e..f2b964d 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -139,6 +139,7 @@ async function runTask( id: t.id, groupFolder: t.group_folder, prompt: t.prompt, + script: t.script, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, @@ -179,6 +180,7 @@ async function runTask( isMain, isScheduledTask: true, assistantName: ASSISTANT_NAME, + script: task.script || undefined, }, (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), From 9f5aff99b68b0dbd9c23f4ac6907f29d2a7036df Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:43:56 +0200 Subject: [PATCH 030/124] feat: add script execution phase to agent-runner Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2cd34c9..382439f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; +import { execFile } from 'child_process'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; @@ -465,6 +466,55 @@ async function runQuery( return { newSessionId, lastAssistantUuid, closedDuringQuery }; } +interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +const SCRIPT_TIMEOUT_MS = 30_000; + +async function runScript(script: string): Promise { + const scriptPath = '/tmp/task-script.sh'; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile('bash', [scriptPath], { + timeout: SCRIPT_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: process.env, + }, (error, stdout, stderr) => { + if (stderr) { + log(`Script stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`Script error: ${error.message}`); + return resolve(null); + } + + // Parse last non-empty line of stdout as JSON + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log('Script produced no output'); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }); + }); +} + async function main(): Promise { let containerInput: ContainerInput; @@ -506,6 +556,26 @@ async function main(): Promise { prompt += '\n' + pending.join('\n'); } + // Script phase: run script before waking agent + if (containerInput.script && containerInput.isScheduledTask) { + log('Running task script...'); + const scriptResult = await runScript(containerInput.script); + + if (!scriptResult || !scriptResult.wakeAgent) { + const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output'; + log(`Script decided not to wake agent: ${reason}`); + writeOutput({ + status: 'success', + result: `Script: ${reason}`, + }); + return; + } + + // Script says wake agent — enrich prompt with script data + log(`Script wakeAgent=true, enriching prompt with data`); + prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; + } + // Query loop: run query → wait for IPC message → run new query → repeat let resumeAt: string | undefined; try { From a4dc3a744668e3202ce97c740d68c4cf1b3bb1a7 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:45:01 +0200 Subject: [PATCH 031/124] docs: add task script instructions to agent CLAUDE.md --- groups/main/CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 11e846b..0580e4b 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -244,3 +244,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` The task will run in that group's context with access to their files and memory. + +--- + +## Task Scripts + +When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency From b7f1d48423646e825500e02618c2a62b12d1dd9f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 14:04:31 +0200 Subject: [PATCH 032/124] style: fix prettier formatting in db.ts --- src/db.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/db.ts b/src/db.ts index 36e3edc..87dd941 100644 --- a/src/db.ts +++ b/src/db.ts @@ -95,9 +95,7 @@ function createSchema(database: Database.Database): void { // Add script column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, - ); + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`); } catch { /* column already exists */ } @@ -420,7 +418,12 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + | 'prompt' + | 'script' + | 'schedule_type' + | 'schedule_value' + | 'next_run' + | 'status' > >, ): void { From 00ff0e00ebd5bc0643956dd6c2b06d0b2857fced Mon Sep 17 00:00:00 2001 From: RichardCao Date: Mon, 23 Mar 2026 16:51:25 +0800 Subject: [PATCH 033/124] fix(db): default Telegram backfill chats to DMs --- src/db-migration.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++ src/db.ts | 7 ++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/db-migration.test.ts diff --git a/src/db-migration.test.ts b/src/db-migration.test.ts new file mode 100644 index 0000000..e26873d --- /dev/null +++ b/src/db-migration.test.ts @@ -0,0 +1,67 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it, vi } from 'vitest'; + +describe('database migrations', () => { + it('defaults Telegram backfill chats to direct messages', async () => { + const repoRoot = process.cwd(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-')); + + try { + process.chdir(tempDir); + fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true }); + + const dbPath = path.join(tempDir, 'store', 'messages.db'); + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE chats ( + jid TEXT PRIMARY KEY, + name TEXT, + last_message_time TEXT + ); + `); + legacyDb + .prepare( + `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, + ) + .run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z'); + legacyDb + .prepare( + `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, + ) + .run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z'); + legacyDb + .prepare( + `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, + ) + .run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z'); + legacyDb.close(); + + vi.resetModules(); + const { initDatabase, getAllChats, _closeDatabase } = + await import('./db.js'); + + initDatabase(); + + const chats = getAllChats(); + expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({ + channel: 'telegram', + is_group: 0, + }); + expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({ + channel: 'telegram', + is_group: 0, + }); + expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({ + channel: 'whatsapp', + is_group: 1, + }); + + _closeDatabase(); + } finally { + process.chdir(repoRoot); + } + }); +}); diff --git a/src/db.ts b/src/db.ts index 0896f41..1a097c0 100644 --- a/src/db.ts +++ b/src/db.ts @@ -134,7 +134,7 @@ function createSchema(database: Database.Database): void { `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, ); database.exec( - `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, + `UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`, ); } catch { /* columns already exist */ @@ -158,6 +158,11 @@ export function _initTestDatabase(): void { createSchema(db); } +/** @internal - for tests only. */ +export function _closeDatabase(): void { + db.close(); +} + /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. From d40affbdef3fb4c86cf6fbe121d43e96693ad78e Mon Sep 17 00:00:00 2001 From: Shawn Yeager Date: Mon, 23 Mar 2026 13:41:20 +0000 Subject: [PATCH 034/124] fix: skip bump-version and update-tokens on forks These workflows use APP_ID/APP_PRIVATE_KEY secrets that only exist on the upstream repo. Without a fork guard they fail on every push for every fork. merge-forward-skills already has the correct guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/bump-version.yml | 1 + .github/workflows/update-tokens.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index fb77595..8191085 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -7,6 +7,7 @@ on: jobs: bump-version: + if: github.repository == 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v1 diff --git a/.github/workflows/update-tokens.yml b/.github/workflows/update-tokens.yml index 753da18..9b25c55 100644 --- a/.github/workflows/update-tokens.yml +++ b/.github/workflows/update-tokens.yml @@ -8,6 +8,7 @@ on: jobs: update-tokens: + if: github.repository == 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v1 From ff16e93713de67312ae029b8bfb6474d75554881 Mon Sep 17 00:00:00 2001 From: Akasha Date: Sun, 22 Mar 2026 16:53:42 -0400 Subject: [PATCH 035/124] fix: skip mount-allowlist write if file already exists /setup overwrote ~/.config/nanoclaw/mount-allowlist.json unconditionally, clobbering any user customizations made after initial setup. Now checks for the file first and skips with a 'skipped' status if it exists. Co-Authored-By: Claude Sonnet 4.6 --- setup/mounts.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup/mounts.ts b/setup/mounts.ts index eb2a5f6..a3377d3 100644 --- a/setup/mounts.ts +++ b/setup/mounts.ts @@ -37,6 +37,21 @@ export async function run(args: string[]): Promise { fs.mkdirSync(configDir, { recursive: true }); + if (fs.existsSync(configFile)) { + logger.info( + { configFile }, + 'Mount allowlist already exists — skipping (use --force to overwrite)', + ); + emitStatus('CONFIGURE_MOUNTS', { + PATH: configFile, + ALLOWED_ROOTS: 0, + NON_MAIN_READ_ONLY: 'unknown', + STATUS: 'skipped', + LOG: 'logs/setup.log', + }); + return; + } + let allowedRoots = 0; let nonMainReadOnly = 'true'; From 5f426465981f6e407412847cae7ea67999cb1e01 Mon Sep 17 00:00:00 2001 From: Akasha Date: Mon, 23 Mar 2026 16:57:09 -0400 Subject: [PATCH 036/124] fix: implement --force flag for mount-allowlist overwrite The skip message mentioned --force but parseArgs didn't handle it, making it a false promise. Now --force is parsed and passed through, allowing users to regenerate the mount allowlist when needed. Co-Authored-By: Claude Sonnet 4.6 --- setup/mounts.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setup/mounts.ts b/setup/mounts.ts index a3377d3..e14d23b 100644 --- a/setup/mounts.ts +++ b/setup/mounts.ts @@ -10,21 +10,23 @@ import { logger } from '../src/logger.js'; import { isRoot } from './platform.js'; import { emitStatus } from './status.js'; -function parseArgs(args: string[]): { empty: boolean; json: string } { +function parseArgs(args: string[]): { empty: boolean; json: string; force: boolean } { let empty = false; let json = ''; + let force = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--empty') empty = true; + if (args[i] === '--force') force = true; if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; } } - return { empty, json }; + return { empty, json, force }; } export async function run(args: string[]): Promise { - const { empty, json } = parseArgs(args); + const { empty, json, force } = parseArgs(args); const homeDir = os.homedir(); const configDir = path.join(homeDir, '.config', 'nanoclaw'); const configFile = path.join(configDir, 'mount-allowlist.json'); @@ -37,7 +39,7 @@ export async function run(args: string[]): Promise { fs.mkdirSync(configDir, { recursive: true }); - if (fs.existsSync(configFile)) { + if (fs.existsSync(configFile) && !force) { logger.info( { configFile }, 'Mount allowlist already exists — skipping (use --force to overwrite)', From 724fe7250dd44b336abc7208d2b37f95db646b8c Mon Sep 17 00:00:00 2001 From: Ken Bolton Date: Mon, 23 Mar 2026 20:27:40 -0400 Subject: [PATCH 037/124] fix(claw): mount group folder and sessions into container claw was running containers with no volume mounts, so the agent always saw an empty /workspace/group. Add build_mounts() to replicate the same bind-mounts that container-runner.ts sets up (group folder, .claude sessions, IPC dir, agent-runner source, and project root for main). Also includes upstream fix from qwibitai/nanoclaw#1368: graceful terminate() before kill() on output sentinel, and early return after a successful structured response so exit code stays 0. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/claw/scripts/claw | 64 ++++++++++++++++++++++++++++++-- src/claw-skill.test.ts | 45 ++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/claw-skill.test.ts diff --git a/.claude/skills/claw/scripts/claw b/.claude/skills/claw/scripts/claw index 3878e48..b64a225 100644 --- a/.claude/skills/claw/scripts/claw +++ b/.claude/skills/claw/scripts/claw @@ -121,8 +121,48 @@ def find_group(groups: list[dict], query: str) -> dict | None: return None -def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None: - cmd = [runtime, "run", "-i", "--rm", image] +def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]: + """Return list of (host_path, container_path, readonly) tuples.""" + groups_dir = NANOCLAW_DIR / "groups" + data_dir = NANOCLAW_DIR / "data" + sessions_dir = data_dir / "sessions" / folder + ipc_dir = data_dir / "ipc" / folder + + # Ensure required dirs exist + group_dir = groups_dir / folder + group_dir.mkdir(parents=True, exist_ok=True) + (sessions_dir / ".claude").mkdir(parents=True, exist_ok=True) + for sub in ("messages", "tasks", "input"): + (ipc_dir / sub).mkdir(parents=True, exist_ok=True) + + agent_runner_src = sessions_dir / "agent-runner-src" + project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src" + if not agent_runner_src.exists() and project_agent_runner.exists(): + import shutil + shutil.copytree(project_agent_runner, agent_runner_src) + + mounts: list[tuple[str, str, bool]] = [] + if is_main: + mounts.append((str(NANOCLAW_DIR), "/workspace/project", True)) + mounts.append((str(group_dir), "/workspace/group", False)) + mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False)) + mounts.append((str(ipc_dir), "/workspace/ipc", False)) + if agent_runner_src.exists(): + mounts.append((str(agent_runner_src), "/app/src", False)) + return mounts + + +def run_container(runtime: str, image: str, payload: dict, + folder: str | None = None, is_main: bool = False, + timeout: int = 300) -> None: + cmd = [runtime, "run", "-i", "--rm"] + if folder: + for host, container, readonly in build_mounts(folder, is_main): + if readonly: + cmd += ["--mount", f"type=bind,source={host},target={container},readonly"] + else: + cmd += ["-v", f"{host}:{container}"] + cmd.append(image) dbg(f"cmd: {' '.join(cmd)}") # Show payload sans secrets @@ -167,7 +207,12 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - dbg("output sentinel found, terminating container") done.set() try: - proc.kill() + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + dbg("graceful stop timed out, force killing container") + proc.kill() except ProcessLookupError: pass return @@ -197,6 +242,8 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - stdout, re.DOTALL, ) + success = False + if match: try: data = json.loads(match.group(1)) @@ -206,6 +253,7 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - session_id = data.get("newSessionId") or data.get("sessionId") if session_id: print(f"\n[session: {session_id}]", file=sys.stderr) + success = True else: print(f"[{status}] {data.get('result', '')}", file=sys.stderr) sys.exit(1) @@ -215,6 +263,9 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - # No structured output — print raw stdout print(stdout) + if success: + return + if proc.returncode not in (0, None): sys.exit(proc.returncode) @@ -273,6 +324,7 @@ def main(): # Resolve group → jid jid = args.jid group_name = None + group_folder = None is_main = False if args.group: @@ -281,6 +333,7 @@ def main(): sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.") jid = g["jid"] group_name = g["name"] + group_folder = g["folder"] is_main = g["is_main"] elif not jid: # Default: main group @@ -288,6 +341,7 @@ def main(): if mains: jid = mains[0]["jid"] group_name = mains[0]["name"] + group_folder = mains[0]["folder"] is_main = True else: sys.exit("error: no group specified and no main group found. Use -g or -j.") @@ -311,7 +365,9 @@ def main(): payload["resumeAt"] = "latest" print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr) - run_container(runtime, args.image, payload, timeout=args.timeout) + run_container(runtime, args.image, payload, + folder=group_folder, is_main=is_main, + timeout=args.timeout) if __name__ == "__main__": diff --git a/src/claw-skill.test.ts b/src/claw-skill.test.ts new file mode 100644 index 0000000..24260c9 --- /dev/null +++ b/src/claw-skill.test.ts @@ -0,0 +1,45 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import { describe, expect, it } from 'vitest'; + +describe('claw skill script', () => { + it('exits zero after successful structured output even if the runtime is terminated', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-')); + const binDir = path.join(tempDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const runtimePath = path.join(binDir, 'container'); + fs.writeFileSync( + runtimePath, + `#!/bin/sh +cat >/dev/null +printf '%s\n' '---NANOCLAW_OUTPUT_START---' '{"status":"success","result":"4","newSessionId":"sess-1"}' '---NANOCLAW_OUTPUT_END---' +sleep 30 +`, + ); + fs.chmodSync(runtimePath, 0o755); + + const result = spawnSync( + 'python3', + ['.claude/skills/claw/scripts/claw', '-j', 'tg:123', 'What is 2+2?'], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + NANOCLAW_DIR: tempDir, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, + timeout: 15000, + }, + ); + + expect(result.status).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stdout).toContain('4'); + expect(result.stderr).toContain('[session: sess-1]'); + }); +}); From 01b6258f59c76eff7ed86c8b9d4aa1d8eecddf46 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 10:40:04 +0100 Subject: [PATCH 038/124] docs: update outdated documentation, add docs portal links - README.md: add docs.nanoclaw.dev link, point architecture and security references to documentation site - CHANGELOG.md: add all releases from v1.1.0 through v1.2.21 (was only v1.2.0), link to full changelog on docs site - docs/REQUIREMENTS.md: update multi-channel references (NanoClaw now supports WhatsApp, Telegram, Discord, Slack, Gmail), update RFS to reflect existing skills, fix deployment info (macOS + Linux) - docs/SECURITY.md: generalize WhatsApp-specific language to channel-neutral - docs/DEBUG_CHECKLIST.md: use Docker commands (default runtime) instead of Apple Container syntax, generalize WhatsApp references - docs/README.md: new file pointing to docs.nanoclaw.dev as the authoritative source, with mapping table from local files to docs site pages Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 138 +++++++++++++++++++++++++++++++++++++++- README.md | 7 +- docs/DEBUG_CHECKLIST.md | 14 ++-- docs/README.md | 15 +++++ docs/REQUIREMENTS.md | 45 ++++++------- docs/SECURITY.md | 6 +- 6 files changed, 182 insertions(+), 43 deletions(-) create mode 100644 docs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb6496..323c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,139 @@ All notable changes to NanoClaw will be documented in this file. -## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0) +For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). -[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved). -- **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669) +## [1.2.21] - 2026-03-22 + +- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again) + +## [1.2.20] - 2026-03-21 + +- Added ESLint configuration with error-handling rules + +## [1.2.19] - 2026-03-19 + +- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag) + +## [1.2.18] - 2026-03-19 + +- User prompt content no longer logged on container errors — only input metadata +- Added Japanese README translation + +## [1.2.17] - 2026-03-18 + +- Added `/capabilities` and `/status` container-agent skills + +## [1.2.16] - 2026-03-18 + +- Tasks snapshot now refreshes immediately after IPC task mutations + +## [1.2.15] - 2026-03-16 + +- Fixed remote-control prompt auto-accept to prevent immediate exit +- Added `KillMode=process` so remote-control survives service restarts + +## [1.2.14] - 2026-03-14 + +- Added `/remote-control` command for host-level Claude Code access from within containers + +## [1.2.13] - 2026-03-14 + +**Breaking:** Skills are now git branches, channels are separate fork repos. + +- Skills live as `skill/*` git branches merged via `git merge` +- Added Docker Sandboxes support +- Fixed setup registration to use correct CLI commands + +## [1.2.12] - 2026-03-08 + +- Added `/compact` skill for manual context compaction +- Enhanced container environment isolation via credential proxy + +## [1.2.11] - 2026-03-08 + +- Added PDF reader, image vision, and WhatsApp reactions skills +- Fixed task container to close promptly when agent uses IPC-only messaging + +## [1.2.10] - 2026-03-06 + +- Added `LIMIT` to unbounded message history queries for better performance + +## [1.2.9] - 2026-03-06 + +- Agent prompts now include timezone context for accurate time references + +## [1.2.8] - 2026-03-06 + +- Fixed misleading `send_message` tool description for scheduled tasks + +## [1.2.7] - 2026-03-06 + +- Added `/add-ollama` skill for local model inference +- Added `update_task` tool and return task ID from `schedule_task` + +## [1.2.6] - 2026-03-04 + +- Updated `claude-agent-sdk` to 0.2.68 + +## [1.2.5] - 2026-03-04 + +- CI formatting fix + +## [1.2.4] - 2026-03-04 + +- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback + +## [1.2.3] - 2026-03-04 + +- Added sender allowlist for per-chat access control + +## [1.2.2] - 2026-03-04 + +- Added `/use-local-whisper` skill for local voice transcription +- Atomic task claims prevent scheduled tasks from executing twice + +## [1.2.1] - 2026-03-02 + +- Version bump (no functional changes) + +## [1.2.0] - 2026-03-02 + +**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add. + +- Channel registry: channels self-register at startup via `registerChannel()` factory pattern +- `isMain` flag replaces folder-name-based main group detection +- `ENABLED_CHANNELS` removed — channels detected by credential presence +- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval + +## [1.1.6] - 2026-03-01 + +- Added CJK font support for Chromium screenshots + +## [1.1.5] - 2026-03-01 + +- Fixed wrapped WhatsApp message normalization + +## [1.1.4] - 2026-03-01 + +- Added third-party model support +- Added `/update-nanoclaw` skill for syncing with upstream + +## [1.1.3] - 2026-02-25 + +- Added `/add-slack` skill +- Restructured Gmail skill for new architecture + +## [1.1.2] - 2026-02-24 + +- Improved error handling for WhatsApp Web version fetch + +## [1.1.1] - 2026-02-24 + +- Added Qodo skills and codebase intelligence +- Fixed WhatsApp 405 connection failures + +## [1.1.0] - 2026-02-23 + +- Added `/update` skill to pull upstream changes from within Claude Code +- Enhanced container environment isolation via credential proxy diff --git a/README.md b/README.md index 3aafd85..8cfe627 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

nanoclaw.dev  •   + docs  •   中文  •   日本語  •   Discord  •   @@ -134,7 +135,7 @@ Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Respon Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. -For the full architecture details, see [docs/SPEC.md](docs/SPEC.md). +For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture). Key files: - `src/index.ts` - Orchestrator: state, message loop, agent invocation @@ -159,7 +160,7 @@ Yes. Docker is the default runtime and works on both macOS and Linux. Just run ` **Is this secure?** -Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. +Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. **Why no configuration files?** @@ -203,7 +204,7 @@ Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42). ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes. +See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site. ## License diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md index 5597067..c1d53f1 100644 --- a/docs/DEBUG_CHECKLIST.md +++ b/docs/DEBUG_CHECKLIST.md @@ -19,16 +19,16 @@ launchctl list | grep nanoclaw # Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) # 2. Any running containers? -container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw +docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 3. Any stopped/orphaned containers? -container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw +docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 4. Recent errors in service log? grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 -# 5. Is WhatsApp connected? (look for last connection event) -grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 +# 5. Are channels connected? (look for last connection event) +grep -E 'Connected|Connection closed|connection.*close|channel.*ready' logs/nanoclaw.log | tail -5 # 6. Are groups loaded? grep 'groupCount' logs/nanoclaw.log | tail -3 @@ -77,7 +77,7 @@ grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 ## Agent Not Responding ```bash -# Check if messages are being received from WhatsApp +# Check if messages are being received from channels grep 'New messages' logs/nanoclaw.log | tail -10 # Check if messages are being processed (container spawned) @@ -107,10 +107,10 @@ sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups; # Test-run a container to check mounts (dry run) # Replace with the group's folder name -container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ +docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ ``` -## WhatsApp Auth Issues +## Channel Auth Issues ```bash # Check if QR code was requested (means auth expired) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bb062e5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# NanoClaw Documentation + +The official documentation is at **[docs.nanoclaw.dev](https://docs.nanoclaw.dev)**. + +The files in this directory are original design documents and developer references. For the most current and accurate information, use the documentation site. + +| This directory | Documentation site | +|---|---| +| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) | +| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) | +| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) | +| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) | +| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) | +| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) | +| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) | diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 227c9ad..8c1a29e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -22,9 +22,9 @@ The entire codebase should be something you can read and understand. One Node.js Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. -### Built for One User +### Built for the Individual User -This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration. +This isn't a framework or a platform. It's software that fits each user's exact needs. You fork the repo, add the channels you want (WhatsApp, Telegram, Discord, Slack, Gmail), and end up with clean code that does exactly what you need. ### Customization = Code Changes @@ -44,41 +44,31 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp. ## RFS (Request for Skills) -Skills we'd love contributors to build: +Skills we'd like to see contributed: ### Communication Channels -Skills to add or switch to different messaging platforms: -- `/add-telegram` - Add Telegram as an input channel -- `/add-slack` - Add Slack as an input channel -- `/add-discord` - Add Discord as an input channel -- `/add-sms` - Add SMS via Twilio or similar -- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely +- `/add-signal` - Add Signal as a channel +- `/add-matrix` - Add Matrix integration -### Container Runtime -The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container: -- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only) - -### Platform Support -- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) -- `/setup-windows` - Windows support via WSL2 + Docker +> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list. --- ## Vision -A personal Claude assistant accessible via WhatsApp, with minimal custom code. +A personal Claude assistant accessible via messaging, with minimal custom code. **Core components:** - **Claude Agent SDK** as the core agent - **Containers** for isolated agent execution (Linux VMs) -- **WhatsApp** as the primary I/O channel +- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need - **Persistent memory** per conversation and globally - **Scheduled tasks** that run Claude and can message back - **Web access** for search and browsing - **Browser automation** via agent-browser **Implementation approach:** -- Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) +- Use existing tools (channel libraries, Claude Agent SDK, MCP servers) - Minimal glue code - File-based systems where possible (CLAUDE.md for memory, folders for groups) @@ -87,7 +77,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Architecture Decisions ### Message Routing -- A router listens to WhatsApp and routes messages based on configuration +- A router listens to connected channels and routes messages based on configuration - Only messages from registered groups are processed - Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Unregistered groups are ignored completely @@ -136,10 +126,11 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Integration Points -### WhatsApp -- Using baileys library for WhatsApp Web connection +### Channels +- WhatsApp (baileys), Telegram (grammy), Discord (discord.js), Slack (@slack/bolt), Gmail (googleapis) +- Each channel lives in a separate fork repo and is added via skills (e.g., `/add-whatsapp`, `/add-telegram`) - Messages stored in SQLite, polled by router -- QR code authentication during setup +- Channels self-register at startup — unconfigured channels are skipped with a warning ### Scheduler - Built-in scheduler runs on the host, spawns containers for task execution @@ -170,12 +161,12 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - Each user gets a custom setup matching their exact needs ### Skills -- `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services -- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) -- `/update` - Pull upstream changes, merge with customizations, run migrations +- `/setup` - Install dependencies, configure channels, start services +- `/customize` - General-purpose skill for adding capabilities +- `/update-nanoclaw` - Pull upstream changes, merge with customizations ### Deployment -- Runs on local Mac via launchd +- Runs on macOS (launchd) or Linux (systemd) - Single Node.js process handles everything --- diff --git a/docs/SECURITY.md b/docs/SECURITY.md index db6fc18..3562fbd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -7,7 +7,7 @@ | Main group | Trusted | Private self-chat, admin control | | Non-main groups | Untrusted | Other users may be malicious | | Container agents | Sandboxed | Isolated execution environment | -| WhatsApp messages | User input | Potential prompt injection | +| Incoming messages | User input | Potential prompt injection | ## Security Boundaries @@ -76,7 +76,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP 5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` **NOT Mounted:** -- WhatsApp session (`store/auth/`) - host only +- Channel auth sessions (`store/auth/`) - host only - Mount allowlist - external, never mounted - Any credentials matching blocked patterns - `.env` is shadowed with `/dev/null` in the project root mount @@ -97,7 +97,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP ``` ┌──────────────────────────────────────────────────────────────────┐ │ UNTRUSTED ZONE │ -│ WhatsApp Messages (potentially malicious) │ +│ Incoming Messages (potentially malicious) │ └────────────────────────────────┬─────────────────────────────────┘ │ ▼ Trigger check, input escaping From 8dcc70cf5cc45628d05b0fec604baab569d60d0a Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 10:48:18 +0100 Subject: [PATCH 039/124] docs: add Windows (WSL2) to supported platforms Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++--- docs/REQUIREMENTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8cfe627..8d1eb37 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Skills we'd like to see: ## Requirements -- macOS or Linux +- macOS, Linux, or Windows (via WSL2) - Node.js 20+ - [Claude Code](https://claude.ai/download) - [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) @@ -154,9 +154,9 @@ Key files: Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. -**Can I run this on Linux?** +**Can I run this on Linux or Windows?** -Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`. +Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`. **Is this secure?** diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 8c1a29e..e7c2376 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -166,7 +166,7 @@ A personal Claude assistant accessible via messaging, with minimal custom code. - `/update-nanoclaw` - Pull upstream changes, merge with customizations ### Deployment -- Runs on macOS (launchd) or Linux (systemd) +- Runs on macOS (launchd), Linux (systemd), or Windows (WSL2) - Single Node.js process handles everything --- From 5a12ddd4cba283bec12bb68c5c712e2dfded1700 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 5 Mar 2026 20:38:32 +0000 Subject: [PATCH 040/124] fix(register): create CLAUDE.md in group folder from template When registering a new group, create CLAUDE.md in the group folder from the appropriate template (groups/main/ for main groups, groups/global/ for others). Without this, the container agent runs with no CLAUDE.md since its CWD is /workspace/group (the group folder). Also update the name-replacement glob to cover all groups/*/CLAUDE.md files rather than only two hardcoded paths, so newly created files and any future group folders are updated correctly. Co-Authored-By: Claude Sonnet 4.6 --- setup/register.ts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index eeafa90..6e32cd8 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,6 +116,27 @@ export async function run(args: string[]): Promise { recursive: true, }); + // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + const groupClaudeMdPath = path.join( + projectRoot, + 'groups', + parsed.folder, + 'CLAUDE.md', + ); + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, groupClaudeMdPath); + logger.info( + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', + ); + } + } + // Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { @@ -124,10 +145,11 @@ export async function run(args: string[]): Promise { 'Updating assistant name', ); - const mdFiles = [ - path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), - path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), - ]; + const groupsDir = path.join(projectRoot, 'groups'); + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { if (fs.existsSync(mdFile)) { From b6e18688c206b2e50961a17cc9232c2b6fd83877 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:35:13 +0100 Subject: [PATCH 041/124] test: add coverage for CLAUDE.md template copy in register step Adds 5 tests verifying the template copy and glob-based name update logic introduced in the parent commit: - copies global template for non-main groups - copies main template for main groups - does not overwrite existing CLAUDE.md - updates name across all groups/*/CLAUDE.md files - handles missing template gracefully (no crash) Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 152 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index d47d95c..b3bd463 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; @@ -6,7 +9,7 @@ import Database from 'better-sqlite3'; * Tests for the register step. * * Verifies: parameterized SQL (no injection), file templating, - * apostrophe in names, .env updates. + * apostrophe in names, .env updates, CLAUDE.md template copy. */ function createTestDb(): Database.Database { @@ -255,3 +258,148 @@ describe('file templating', () => { expect(envContent).toContain('ASSISTANT_NAME="Nova"'); }); }); + +describe('CLAUDE.md template copy', () => { + let tmpDir: string; + let groupsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); + groupsDir = path.join(tmpDir, 'groups'); + fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true }); + fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true }); + fs.writeFileSync( + path.join(groupsDir, 'main', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.', + ); + fs.writeFileSync( + path.join(groupsDir, 'global', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('copies global template for non-main group', () => { + const folder = 'telegram_dev-team'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + // Replicate register.ts logic: copy template if dest doesn't exist + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); + // Should NOT contain main-specific content + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + }); + + it('copies main template for main group', () => { + const folder = 'whatsapp_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const isMain = true; + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + }); + + it('does not overwrite existing CLAUDE.md', () => { + const folder = 'slack_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(folderDir, { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); + + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); + }); + + it('updates name in all groups/*/CLAUDE.md files', () => { + // Create a few group folders with CLAUDE.md + for (const folder of ['whatsapp_main', 'telegram_friends']) { + const dir = path.join(groupsDir, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + } + + const assistantName = 'Luna'; + + // Replicate register.ts glob logic + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace(/You are Andy/g, `You are ${assistantName}`); + fs.writeFileSync(mdFile, content); + } + + // All CLAUDE.md files should be updated, including templates and groups + for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { + const content = fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + expect(content).toContain('# Luna'); + expect(content).toContain('You are Luna'); + expect(content).not.toContain('Andy'); + } + }); + + it('handles missing template gracefully', () => { + // Remove templates + fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); + fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); + + const folder = 'discord_general'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // No crash, no file created + expect(fs.existsSync(dest)).toBe(false); + }); +}); From 07dc8c977c9b2c582896996dbe2bb4936e94269d Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:39:21 +0100 Subject: [PATCH 042/124] test: cover multi-channel main and cross-channel name propagation Replaces single-channel tests with multi-channel scenarios: - each channel can have its own main with admin context - non-main groups across channels get global template - custom name propagates to all channels and groups - user-modified CLAUDE.md preserved on re-registration - missing templates handled gracefully Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 212 +++++++++++++++++++++++------------------ 1 file changed, 118 insertions(+), 94 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index b3bd463..11f0f5f 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -263,6 +263,52 @@ describe('CLAUDE.md template copy', () => { let tmpDir: string; let groupsDir: string; + // Replicates register.ts template copy + name update logic + function simulateRegister( + folder: string, + isMain: boolean, + assistantName = 'Andy', + ): void { + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + // Template copy (register.ts lines 119-138) + const dest = path.join(folderDir, 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // Name update across all groups (register.ts lines 140-165) + if (assistantName !== 'Andy') { + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace( + /You are Andy/g, + `You are ${assistantName}`, + ); + fs.writeFileSync(mdFile, content); + } + } + } + + function readGroupMd(folder: string): string { + return fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + } + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); groupsDir = path.join(tmpDir, 'groups'); @@ -283,123 +329,101 @@ describe('CLAUDE.md template copy', () => { }); it('copies global template for non-main group', () => { - const folder = 'telegram_dev-team'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('telegram_dev-team', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - // Replicate register.ts logic: copy template if dest doesn't exist - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); - // Should NOT contain main-specific content - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + const content = readGroupMd('telegram_dev-team'); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); }); it('copies main template for main group', () => { - const folder = 'whatsapp_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('whatsapp_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - const isMain = true; - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); }); - it('does not overwrite existing CLAUDE.md', () => { - const folder = 'slack_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(folderDir, { recursive: true }); + it('each channel can have its own main with admin context', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_main', true); + simulateRegister('discord_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); - - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } + for (const folder of [ + 'whatsapp_main', + 'telegram_main', + 'slack_main', + 'discord_main', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Andy'); } - - expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); }); - it('updates name in all groups/*/CLAUDE.md files', () => { - // Create a few group folders with CLAUDE.md - for (const folder of ['whatsapp_main', 'telegram_friends']) { - const dir = path.join(groupsDir, folder); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'CLAUDE.md'), - '# Andy\n\nYou are Andy, a personal assistant.', - ); + it('non-main groups across channels get global template', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_friends', false); + simulateRegister('slack_engineering', false); + simulateRegister('discord_general', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + for (const folder of [ + 'telegram_friends', + 'slack_engineering', + 'discord_general', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); } + }); - const assistantName = 'Luna'; + it('custom name propagates to all channels and groups', () => { + // Register multiple channels, last one sets custom name + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_devs', false); + // Final registration triggers name update across all + simulateRegister('discord_main', true, 'Luna'); - // Replicate register.ts glob logic - const mdFiles = fs - .readdirSync(groupsDir) - .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) - .filter((f) => fs.existsSync(f)); - - for (const mdFile of mdFiles) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${assistantName}`); - content = content.replace(/You are Andy/g, `You are ${assistantName}`); - fs.writeFileSync(mdFile, content); - } - - // All CLAUDE.md files should be updated, including templates and groups - for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { - const content = fs.readFileSync( - path.join(groupsDir, folder, 'CLAUDE.md'), - 'utf-8', - ); + for (const folder of [ + 'main', + 'global', + 'whatsapp_main', + 'telegram_main', + 'slack_devs', + 'discord_main', + ]) { + const content = readGroupMd(folder); expect(content).toContain('# Luna'); expect(content).toContain('You are Luna'); expect(content).not.toContain('Andy'); } }); - it('handles missing template gracefully', () => { - // Remove templates + it('does not overwrite user-modified CLAUDE.md', () => { + simulateRegister('slack_main', true); + // User customizes the file + fs.writeFileSync( + path.join(groupsDir, 'slack_main', 'CLAUDE.md'), + '# Custom\n\nUser-modified content.', + ); + // Re-registering same folder (e.g. re-running /add-slack) + simulateRegister('slack_main', true); + + const content = readGroupMd('slack_main'); + expect(content).toContain('User-modified content'); + expect(content).not.toContain('Admin Context'); + }); + + it('handles missing templates gracefully', () => { fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); - const folder = 'discord_general'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('discord_general', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - // No crash, no file created - expect(fs.existsSync(dest)).toBe(false); + expect( + fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')), + ).toBe(false); }); }); From 3207c35e50540618c990f8062b4c8a5d40ae0621 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:44:24 +0100 Subject: [PATCH 043/124] fix: promote CLAUDE.md to main template when group becomes main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-main group is re-registered with --is-main, the existing CLAUDE.md (copied from global template) lacked admin context. Now register.ts detects this promotion case and replaces it with the main template. Files that already contain "## Admin Context" are preserved. Adds tests for: - promoting non-main to main upgrades the template - cross-channel promotion (e.g. Telegram non-main → main) - promotion with custom assistant name - re-registration preserves user-modified main CLAUDE.md - re-registration preserves user-modified non-main CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 79 +++++++++++++++++++++++++++++++++++------- setup/register.ts | 29 ++++++++++++---- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 11f0f5f..859a457 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,18 +272,24 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy (register.ts lines 119-138) + // Template copy + promotion (register.ts lines 119-148) const dest = path.join(folderDir, 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + const fileExists = fs.existsSync(dest); + const needsPromotion = + isMain && + fileExists && + !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 140-165) + // Name update across all groups (register.ts lines 150-175) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -401,19 +407,66 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite user-modified CLAUDE.md', () => { + it('does not overwrite main CLAUDE.md that already has admin context', () => { simulateRegister('slack_main', true); - // User customizes the file - fs.writeFileSync( - path.join(groupsDir, 'slack_main', 'CLAUDE.md'), - '# Custom\n\nUser-modified content.', - ); + // User appends custom content to the main template + const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); + fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); + // Preserved: has both admin context AND user additions + expect(content).toContain('Admin Context'); + expect(content).toContain('My Custom Section'); + }); + + it('does not overwrite non-main CLAUDE.md on re-registration', () => { + simulateRegister('telegram_friends', false); + // User customizes the file + const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); + fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); + // Re-registering same folder as non-main + simulateRegister('telegram_friends', false); + + const content = readGroupMd('telegram_friends'); expect(content).toContain('User-modified content'); - expect(content).not.toContain('Admin Context'); + }); + + it('promotes non-main group to main when re-registered with isMain', () => { + // Initially registered as non-main (gets global template) + simulateRegister('telegram_main', false); + expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); + + // User switches this channel to main + simulateRegister('telegram_main', true); + expect(readGroupMd('telegram_main')).toContain('Admin Context'); + }); + + it('promotes across channels — WhatsApp non-main to Telegram main', () => { + // Start with WhatsApp as main, Telegram as non-main + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_control', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); + + // User decides Telegram should be the new main + simulateRegister('telegram_control', true); + expect(readGroupMd('telegram_control')).toContain('Admin Context'); + }); + + it('promotion updates assistant name in promoted file', () => { + // Register as non-main with default name + simulateRegister('slack_ops', false); + expect(readGroupMd('slack_ops')).toContain('You are Andy'); + + // Promote to main with custom name + simulateRegister('slack_ops', true, 'Nova'); + const content = readGroupMd('slack_ops'); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Nova'); + expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 6e32cd8..270ebfa 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,7 +116,7 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // Create or upgrade CLAUDE.md in the group folder from the appropriate template. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. const groupClaudeMdPath = path.join( projectRoot, @@ -124,15 +124,30 @@ export async function run(args: string[]): Promise { parsed.folder, 'CLAUDE.md', ); - if (!fs.existsSync(groupClaudeMdPath)) { - const templatePath = parsed.isMain - ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') - : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); + const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; + const fileExists = fs.existsSync(groupClaudeMdPath); + + // Promotion case: group was registered as non-main (got global template) + // and is now being re-registered as main. Replace with main template. + const needsPromotion = + parsed.isMain && + fileExists && + !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { file: groupClaudeMdPath, template: templatePath }, - 'Created CLAUDE.md from template', + { + file: groupClaudeMdPath, + template: templatePath, + promoted: needsPromotion, + }, + needsPromotion + ? 'Promoted CLAUDE.md to main template' + : 'Created CLAUDE.md from template', ); } } From 57085cc02e42ef9be73ca5b5da903da11b898971 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 13:09:26 +0100 Subject: [PATCH 044/124] =?UTF-8?q?fix:=20revert=20promotion=20logic=20?= =?UTF-8?q?=E2=80=94=20never=20overwrite=20existing=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promotion logic (overwriting CLAUDE.md when a group becomes main) is unsafe. Real-world setups use is_main for groups that intentionally lack admin context — e.g. a family chat (whatsapp_casa) with 144 lines of custom persona, PARA workspace, task management, and family context. Overwriting based on missing "## Admin Context" would destroy user work. register.ts now follows a simple rule: create template for new folders, never touch existing files. Tests updated to verify preservation across re-registration and main promotion scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 98 +++++++++++++++++------------------------- setup/register.ts | 32 +++++--------- 2 files changed, 50 insertions(+), 80 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 859a457..5a70740 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,24 +272,18 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy + promotion (register.ts lines 119-148) + // Template copy — never overwrite existing (register.ts lines 119-135) const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - const fileExists = fs.existsSync(dest); - const needsPromotion = - isMain && - fileExists && - !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 150-175) + // Name update across all groups (register.ts lines 140-165) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -407,66 +401,54 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite main CLAUDE.md that already has admin context', () => { + it('never overwrites existing CLAUDE.md on re-registration', () => { simulateRegister('slack_main', true); - // User appends custom content to the main template + // User customizes the file extensively (persona, workspace, rules) const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); - fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); + fs.writeFileSync( + mdPath, + '# Gambi\n\nCustom persona with workspace rules and family context.', + ); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); - // Preserved: has both admin context AND user additions - expect(content).toContain('Admin Context'); - expect(content).toContain('My Custom Section'); + expect(content).toContain('Custom persona'); + expect(content).not.toContain('Admin Context'); }); - it('does not overwrite non-main CLAUDE.md on re-registration', () => { - simulateRegister('telegram_friends', false); - // User customizes the file - const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); - fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); - // Re-registering same folder as non-main - simulateRegister('telegram_friends', false); + it('never overwrites when non-main becomes main (isMain changes)', () => { + // User registers a family group as non-main + simulateRegister('whatsapp_casa', false); + // User extensively customizes it (PARA system, task management, etc.) + const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md'); + fs.writeFileSync( + mdPath, + '# Casa\n\nFamily group with PARA system, task management, shopping lists.', + ); + // Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved + simulateRegister('whatsapp_casa', true); - const content = readGroupMd('telegram_friends'); - expect(content).toContain('User-modified content'); + const content = readGroupMd('whatsapp_casa'); + expect(content).toContain('PARA system'); + expect(content).not.toContain('Admin Context'); }); - it('promotes non-main group to main when re-registered with isMain', () => { - // Initially registered as non-main (gets global template) - simulateRegister('telegram_main', false); - expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); - - // User switches this channel to main - simulateRegister('telegram_main', true); - expect(readGroupMd('telegram_main')).toContain('Admin Context'); - }); - - it('promotes across channels — WhatsApp non-main to Telegram main', () => { - // Start with WhatsApp as main, Telegram as non-main + it('preserves custom CLAUDE.md across channels when changing main', () => { + // Real-world scenario: WhatsApp main + customized Discord research channel simulateRegister('whatsapp_main', true); - simulateRegister('telegram_control', false); + simulateRegister('discord_main', false); + const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md'); + fs.writeFileSync( + discordPath, + '# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.', + ); + // Discord becomes main too — custom content must survive + simulateRegister('discord_main', true); + expect(readGroupMd('discord_main')).toContain('Research Assistant'); + // WhatsApp main also untouched expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); - expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); - - // User decides Telegram should be the new main - simulateRegister('telegram_control', true); - expect(readGroupMd('telegram_control')).toContain('Admin Context'); - }); - - it('promotion updates assistant name in promoted file', () => { - // Register as non-main with default name - simulateRegister('slack_ops', false); - expect(readGroupMd('slack_ops')).toContain('You are Andy'); - - // Promote to main with custom name - simulateRegister('slack_ops', true, 'Nova'); - const content = readGroupMd('slack_ops'); - expect(content).toContain('Admin Context'); - expect(content).toContain('You are Nova'); - expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 270ebfa..c08d910 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,38 +116,26 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create or upgrade CLAUDE.md in the group folder from the appropriate template. + // Create CLAUDE.md in the new group folder from template if it doesn't exist. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + // Never overwrite an existing CLAUDE.md — users customize these extensively + // (persona, workspace structure, communication rules, family context, etc.) + // and a stock template replacement would destroy that work. const groupClaudeMdPath = path.join( projectRoot, 'groups', parsed.folder, 'CLAUDE.md', ); - const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); - const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); - const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; - const fileExists = fs.existsSync(groupClaudeMdPath); - - // Promotion case: group was registered as non-main (got global template) - // and is now being re-registered as main. Replace with main template. - const needsPromotion = - parsed.isMain && - fileExists && - !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { - file: groupClaudeMdPath, - template: templatePath, - promoted: needsPromotion, - }, - needsPromotion - ? 'Promoted CLAUDE.md to main template' - : 'Created CLAUDE.md from template', + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', ); } } From d05a8dec495713a09bbe7ed8d73c480758d49910 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 24 Mar 2026 15:21:13 +0000 Subject: [PATCH 045/124] fix: refresh stale agent-runner source cache on code changes Closes #1361 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 1dc607f..47a8387 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,8 +191,17 @@ function buildVolumeMounts( group.folder, 'agent-runner-src', ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + if (fs.existsSync(agentRunnerSrc)) { + const srcIndex = path.join(agentRunnerSrc, 'index.ts'); + const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); + const needsCopy = + !fs.existsSync(groupAgentRunnerDir) || + !fs.existsSync(cachedIndex) || + (fs.existsSync(srcIndex) && + fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + if (needsCopy) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } } mounts.push({ hostPath: groupAgentRunnerDir, From 0015931e37c7d2f66251b02276fc2922c5529672 Mon Sep 17 00:00:00 2001 From: MrBob Date: Tue, 24 Mar 2026 12:26:17 -0300 Subject: [PATCH 046/124] fix: honor per-group trigger patterns --- src/config.ts | 22 +++++++++++++---- src/formatting.test.ts | 56 +++++++++++++++++++++++++++++++++++------- src/index.ts | 11 ++++++--- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/config.ts b/src/config.ts index 63d1207..fc3fc95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,11 @@ import path from 'path'; import { readEnvFile } from './env.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL']); +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'ONECLI_URL', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -58,10 +62,18 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export const TRIGGER_PATTERN = new RegExp( - `^@${escapeRegex(ASSISTANT_NAME)}\\b`, - 'i', -); +export function buildTriggerPattern(trigger: string): RegExp { + return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i'); +} + +export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`; + +export function getTriggerPattern(trigger?: string): RegExp { + const normalizedTrigger = trigger?.trim(); + return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER); +} + +export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER); // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 8a2160c..a630f20 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; +import { + ASSISTANT_NAME, + getTriggerPattern, + TRIGGER_PATTERN, +} from './config.js'; import { escapeXml, formatMessages, @@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => { }); }); +describe('getTriggerPattern', () => { + it('uses the configured per-group trigger when provided', () => { + const pattern = getTriggerPattern('@Claw'); + + expect(pattern.test('@Claw hello')).toBe(true); + expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false); + }); + + it('falls back to the default trigger when group trigger is missing', () => { + const pattern = getTriggerPattern(undefined); + + expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true); + }); + + it('treats regex characters in custom triggers literally', () => { + const pattern = getTriggerPattern('@C.L.A.U.D.E'); + + expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true); + expect(pattern.test('@CXLXAUXDXE hello')).toBe(false); + }); +}); + // --- Outbound formatting (internal tag stripping + prefix) --- describe('stripInternalTags', () => { @@ -207,7 +233,7 @@ describe('formatOutbound', () => { describe('trigger gating (requiresTrigger interaction)', () => { // Replicates the exact logic from processGroupMessages and startMessageLoop: - // if (!isMainGroup && group.requiresTrigger !== false) { check trigger } + // if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger } function shouldRequireTrigger( isMainGroup: boolean, requiresTrigger: boolean | undefined, @@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => { function shouldProcess( isMainGroup: boolean, requiresTrigger: boolean | undefined, + trigger: string | undefined, messages: NewMessage[], ): boolean { if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true; - return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim())); + const triggerPattern = getTriggerPattern(trigger); + return messages.some((m) => triggerPattern.test(m.content.trim())); } it('main group always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, undefined, msgs)).toBe(true); + expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true); }); it('main group processes even with requiresTrigger=true', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, true, msgs)).toBe(true); + expect(shouldProcess(true, true, undefined, msgs)).toBe(true); }); it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, undefined, msgs)).toBe(false); + expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true requires trigger', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, true, msgs)).toBe(false); + expect(shouldProcess(false, true, undefined, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true processes when trigger present', () => { const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; - expect(shouldProcess(false, true, msgs)).toBe(true); + expect(shouldProcess(false, true, undefined, msgs)).toBe(true); + }); + + it('non-main group uses its per-group trigger instead of the default trigger', () => { + const msgs = [makeMsg({ content: '@Claw do something' })]; + expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true); + }); + + it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => { + const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; + expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false); }); it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, false, msgs)).toBe(true); + expect(shouldProcess(false, false, undefined, msgs)).toBe(true); }); }); diff --git a/src/index.ts b/src/index.ts index 3f5e710..5116738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,12 @@ import { OneCLI } from '@onecli-sh/sdk'; import { ASSISTANT_NAME, + DEFAULT_TRIGGER, + getTriggerPattern, IDLE_TIMEOUT, ONECLI_URL, POLL_INTERVAL, TIMEZONE, - TRIGGER_PATTERN, } from './config.js'; import './channels/index.js'; import { @@ -194,10 +195,11 @@ async function processGroupMessages(chatJid: string): Promise { // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; @@ -376,7 +378,7 @@ async function startMessageLoop(): Promise { } messageLoopRunning = true; - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`); while (true) { try { @@ -422,10 +424,11 @@ async function startMessageLoop(): Promise { // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = groupMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); From bf9b7d0311fe1a951c8bee21c870b809e94d8f72 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 00:39:20 +0200 Subject: [PATCH 047/124] fix: auto-resolve package-lock/badge/version conflicts in fork sync The fork-sync and merge-forward workflows were failing on every run because package-lock.json, package.json (version), and badge.svg always conflict between upstream and forks. These are always safe to take from upstream/main. Now auto-resolves these trivial conflicts and only fails on real code conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/fork-sync-skills.yml | 57 ++++++++++++++++++---- .github/workflows/merge-forward-skills.yml | 25 ++++++++-- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 8d25ee2..0d8433e 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -62,14 +62,34 @@ jobs: # Merge upstream main into fork's main if ! git merge upstream/main --no-edit; then - echo "::error::Failed to merge upstream/main into fork main — conflicts detected" - git merge --abort - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 + # Auto-resolve trivial conflicts (lockfile, badge, package.json version) + CONFLICTED=$(git diff --name-only --diff-filter=U) + AUTO_RESOLVABLE=true + for f in $CONFLICTED; do + case "$f" in + package-lock.json|package.json|repo-tokens/badge.svg) + git checkout --theirs "$f" + git add "$f" + ;; + *) + AUTO_RESOLVABLE=false + ;; + esac + done + + if [ "$AUTO_RESOLVABLE" = false ]; then + echo "::error::Failed to merge upstream/main into fork main — non-trivial conflicts detected" + git merge --abort + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit --no-edit + echo "Auto-resolved lockfile/badge/version conflicts" fi - # Validate build + # Regenerate lockfile to match merged package.json npm ci if ! npm run build; then echo "::error::Build failed after merging upstream/main" @@ -115,10 +135,27 @@ jobs: git checkout -B "$BRANCH" "origin/$BRANCH" if ! git merge main --no-edit; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue + # Auto-resolve trivial conflicts + CONFLICTED=$(git diff --name-only --diff-filter=U) + CAN_AUTO=true + for f in $CONFLICTED; do + case "$f" in + package-lock.json|package.json|repo-tokens/badge.svg) + git checkout --theirs "$f" + git add "$f" + ;; + *) + CAN_AUTO=false + ;; + esac + done + if [ "$CAN_AUTO" = false ]; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + git commit --no-edit fi # Check if there's anything new to push diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml index 093130a..b648eb1 100644 --- a/.github/workflows/merge-forward-skills.yml +++ b/.github/workflows/merge-forward-skills.yml @@ -52,10 +52,27 @@ jobs: # Attempt merge if ! git merge main --no-edit; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue + # Auto-resolve trivial conflicts + CONFLICTED=$(git diff --name-only --diff-filter=U) + CAN_AUTO=true + for f in $CONFLICTED; do + case "$f" in + package-lock.json|package.json|repo-tokens/badge.svg) + git checkout --theirs "$f" + git add "$f" + ;; + *) + CAN_AUTO=false + ;; + esac + done + if [ "$CAN_AUTO" = false ]; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + git commit --no-edit fi # Check if there's anything new to push From e26e1b3e68d0a7d04de85d22485b707dab30c2a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 22:39:38 +0000 Subject: [PATCH 048/124] chore: bump version to 1.2.24 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb2f894..39bc424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.23", + "version": "1.2.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.23", + "version": "1.2.24", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index e759922..ed96d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.23", + "version": "1.2.24", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 4d853c5d38c2a6de7635dcf46ba4c286e0ad1f82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 22:39:42 +0000 Subject: [PATCH 049/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.2k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index f29ff27..fedb84a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 39.9k tokens, 20% of context window + + 42.2k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 39.9k + + 42.2k From 2142f03eaf2abf52546fd8a8c0bcfa5d250d1a30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 22:40:34 +0000 Subject: [PATCH 050/124] chore: bump version to 1.2.25 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39bc424..64503e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index ed96d45..fa75541 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 616c1ae10a26672b621a414b90acca049c349686 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 00:44:15 +0200 Subject: [PATCH 051/124] fix: expand auto-resolve patterns and add missing forks to dispatch - Auto-resolve .env.example (keep fork's channel-specific vars) and .github/workflows/* (always take upstream) during fork sync - Add docker-sandbox and docker-sandbox-windows to dispatch list Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/fork-sync-skills.yml | 7 ++++++- .github/workflows/merge-forward-skills.yml | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 0d8433e..4191695 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -67,10 +67,15 @@ jobs: AUTO_RESOLVABLE=true for f in $CONFLICTED; do case "$f" in - package-lock.json|package.json|repo-tokens/badge.svg) + package-lock.json|package.json|repo-tokens/badge.svg|.github/workflows/*) git checkout --theirs "$f" git add "$f" ;; + .env.example) + # Keep fork's channel-specific env vars + git checkout --ours "$f" + git add "$f" + ;; *) AUTO_RESOLVABLE=false ;; diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml index b648eb1..82471b0 100644 --- a/.github/workflows/merge-forward-skills.yml +++ b/.github/workflows/merge-forward-skills.yml @@ -160,6 +160,8 @@ jobs: 'nanoclaw-slack', 'nanoclaw-gmail', 'nanoclaw-docker-sandboxes', + 'nanoclaw-docker-sandbox', + 'nanoclaw-docker-sandbox-windows', ]; const sha = context.sha.substring(0, 7); for (const repo of forks) { From 11847a1af0866e7353560e5b0f6e52f6d29e342c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 01:03:43 +0200 Subject: [PATCH 052/124] fix: validate timezone to prevent crash on POSIX-style TZ values POSIX-style TZ strings like IST-2 cause a hard RangeError crash in formatMessages because Intl.DateTimeFormat only accepts IANA identifiers. - Add isValidTimezone/resolveTimezone helpers to src/timezone.ts - Make formatLocalTime fall back to UTC on invalid timezone - Validate TZ candidates in config.ts before accepting - Add timezone setup step to detect and prompt when autodetection fails - Use node:22-slim in Dockerfile (node:24-slim Trixie package renames) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/SKILL.md | 7 + container/Dockerfile | 2 +- package-lock.json | 347 ++++++++++++++++------------------ package.json | 4 +- setup/index.ts | 1 + setup/timezone.ts | 67 +++++++ src/config.ts | 20 +- src/timezone.test.ts | 46 ++++- src/timezone.ts | 23 ++- 9 files changed, 326 insertions(+), 191 deletions(-) create mode 100644 setup/timezone.ts diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 28a3608..e12e0ea 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -98,6 +98,13 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block. - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure - Record APPLE_CONTAINER and DOCKER values for step 3 +## 2a. Timezone + +Run `npx tsx setup/index.ts --step timezone` and parse the status block. + +- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz `. +- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference. + ## 3. Container Runtime ### 3a. Choose runtime diff --git a/container/Dockerfile b/container/Dockerfile index 2fe1b22..e8537c3 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,7 +1,7 @@ # NanoClaw Agent Container # Runs Claude Agent SDK in isolated Linux VM with browser automation -FROM node:24-slim +FROM node:22-slim # Install system dependencies for Chromium RUN apt-get update && apt-get install -y \ diff --git a/package-lock.json b/package-lock.json index 39bc424..3f17016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "grammy": "^1.39.3", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -542,7 +543,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -561,7 +561,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -574,7 +573,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -584,7 +582,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -599,7 +596,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0" }, @@ -612,7 +608,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -625,7 +620,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", @@ -649,7 +643,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -662,7 +655,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -675,7 +667,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -688,7 +679,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -698,7 +688,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -707,12 +696,17 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grammyjs/types": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } @@ -722,7 +716,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -736,7 +729,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -750,7 +742,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -791,7 +782,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz", "integrity": "sha512-u7PqWROEvTV9f0ADVkjigTrd2AZn3klbPrv7GGpeRHIJpjAxJUdlWqxr5kiGt6qTDKL8t3nq76xr4X2pxTiyBg==", - "license": "MIT", "engines": { "node": ">=20" } @@ -1198,8 +1188,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/node": { "version": "22.19.11", @@ -1212,17 +1201,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", - "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/type-utils": "8.57.1", - "@typescript-eslint/utils": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -1235,7 +1223,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.1", + "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1245,22 +1233,20 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", - "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "engines": { @@ -1276,14 +1262,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", - "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.1", - "@typescript-eslint/types": "^8.57.1", + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "engines": { @@ -1298,14 +1283,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", - "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1316,11 +1300,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", - "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1333,15 +1316,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", - "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -1358,11 +1340,10 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1372,16 +1353,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", - "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.1", - "@typescript-eslint/tsconfig-utils": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1404,17 +1384,15 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -1427,7 +1405,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, @@ -1439,16 +1416,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", - "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1463,13 +1439,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", - "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1485,7 +1460,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -1635,12 +1609,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1653,7 +1638,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -1663,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1680,7 +1663,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -1695,8 +1677,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/assertion-error": { "version": "2.0.1", @@ -1733,8 +1714,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -1792,7 +1772,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1827,7 +1806,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -1847,7 +1825,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1870,7 +1847,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1882,8 +1858,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -1895,8 +1870,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cron-parser": { "version": "5.5.0", @@ -1915,7 +1889,6 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1938,7 +1911,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1980,8 +1952,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/detect-libc": { "version": "2.1.2", @@ -2055,7 +2026,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -2068,7 +2038,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2128,7 +2097,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-no-catch-all/-/eslint-plugin-no-catch-all-1.1.0.tgz", "integrity": "sha512-VkP62jLTmccPrFGN/W6V7a3SEwdtTZm+Su2k4T3uyJirtkm0OMMm97h7qd8pRFAHus/jQg9FpUpLRc7sAylBEQ==", "dev": true, - "license": "MIT", "peerDependencies": { "eslint": ">=2.0.0" } @@ -2138,7 +2106,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2155,7 +2122,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2168,7 +2134,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -2186,7 +2151,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -2199,7 +2163,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2212,7 +2175,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -2232,11 +2194,19 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2266,22 +2236,19 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -2312,7 +2279,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, @@ -2331,7 +2297,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2348,7 +2313,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -2361,8 +2325,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/fs-constants": { "version": "1.0.0", @@ -2409,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2422,7 +2384,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -2430,6 +2391,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/grammy": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.25.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2494,7 +2470,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -2504,7 +2479,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2521,7 +2495,6 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -2543,7 +2516,6 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2553,7 +2525,6 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2565,8 +2536,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -2628,7 +2598,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2640,29 +2609,25 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -2672,7 +2637,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -2686,7 +2650,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -2701,8 +2664,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/luxon": { "version": "3.7.2", @@ -2768,7 +2730,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2795,7 +2756,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2827,8 +2787,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/node-abi": { "version": "3.87.0", @@ -2842,6 +2801,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2876,7 +2855,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -2894,7 +2872,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2910,7 +2887,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -2926,7 +2902,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -2939,7 +2914,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2949,7 +2923,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3111,7 +3084,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -3163,7 +3135,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -3226,7 +3197,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -3348,7 +3318,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3361,7 +3330,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3575,12 +3543,17 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -3625,7 +3598,6 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -3648,16 +3620,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", - "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.1", - "@typescript-eslint/parser": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1" + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3683,7 +3654,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -3847,12 +3817,27 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -3885,7 +3870,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3916,7 +3900,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index ed96d45..b710e94 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,12 @@ "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", - "grammy": "^1.39.3", "cron-parser": "^5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", - "zod": "^4.3.6" + "zod": "^4.3.6", + "grammy": "^1.39.3" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/setup/index.ts b/setup/index.ts index 7ac13e2..7e10ddc 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -9,6 +9,7 @@ const STEPS: Record< string, () => Promise<{ run: (args: string[]) => Promise }> > = { + timezone: () => import('./timezone.js'), environment: () => import('./environment.js'), container: () => import('./container.js'), groups: () => import('./groups.js'), diff --git a/setup/timezone.ts b/setup/timezone.ts new file mode 100644 index 0000000..22c0394 --- /dev/null +++ b/setup/timezone.ts @@ -0,0 +1,67 @@ +/** + * Step: timezone — Detect, validate, and persist the user's timezone. + * Writes TZ to .env if a valid IANA timezone is resolved. + * Emits NEEDS_USER_INPUT=true when autodetection fails. + */ +import fs from 'fs'; +import path from 'path'; + +import { isValidTimezone } from '../src/timezone.js'; +import { logger } from '../src/logger.js'; +import { emitStatus } from './status.js'; + +export async function run(args: string[]): Promise { + const projectRoot = process.cwd(); + const envFile = path.join(projectRoot, '.env'); + + // Check what's already in .env + let envFileTz: string | undefined; + if (fs.existsSync(envFile)) { + const content = fs.readFileSync(envFile, 'utf-8'); + const match = content.match(/^TZ=(.+)$/m); + if (match) envFileTz = match[1].trim().replace(/^["']|["']$/g, ''); + } + + const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const envTz = process.env.TZ; + + // Accept --tz flag from CLI (used when setup skill collects from user) + const tzFlagIdx = args.indexOf('--tz'); + const userTz = tzFlagIdx !== -1 ? args[tzFlagIdx + 1] : undefined; + + // Resolve: user-provided > .env > process.env > system autodetect + let resolvedTz: string | undefined; + for (const candidate of [userTz, envFileTz, envTz, systemTz]) { + if (candidate && isValidTimezone(candidate)) { + resolvedTz = candidate; + break; + } + } + + const needsUserInput = !resolvedTz; + + if (resolvedTz && resolvedTz !== envFileTz) { + // Write/update TZ in .env + if (fs.existsSync(envFile)) { + let content = fs.readFileSync(envFile, 'utf-8'); + if (/^TZ=/m.test(content)) { + content = content.replace(/^TZ=.*$/m, `TZ=${resolvedTz}`); + } else { + content = content.trimEnd() + `\nTZ=${resolvedTz}\n`; + } + fs.writeFileSync(envFile, content); + } else { + fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`); + } + logger.info({ timezone: resolvedTz }, 'Set TZ in .env'); + } + + emitStatus('TIMEZONE', { + SYSTEM_TZ: systemTz || 'unknown', + ENV_TZ: envTz || 'unset', + ENV_FILE_TZ: envFileTz || 'unset', + RESOLVED_TZ: resolvedTz || 'none', + NEEDS_USER_INPUT: needsUserInput, + STATUS: needsUserInput ? 'needs_input' : 'success', + }); +} diff --git a/src/config.ts b/src/config.ts index 26f31c2..d5005a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,12 +2,14 @@ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; +import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). const envConfig = readEnvFile([ 'ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', + 'TZ', ]); export const ASSISTANT_NAME = @@ -67,7 +69,17 @@ export const TRIGGER_PATTERN = new RegExp( 'i', ); -// Timezone for scheduled tasks (cron expressions, etc.) -// Uses system timezone by default -export const TIMEZONE = - process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; +// Timezone for scheduled tasks, message formatting, etc. +// Validates each candidate is a real IANA identifier before accepting. +function resolveConfigTimezone(): string { + const candidates = [ + process.env.TZ, + envConfig.TZ, + Intl.DateTimeFormat().resolvedOptions().timeZone, + ]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; +} +export const TIMEZONE = resolveConfigTimezone(); diff --git a/src/timezone.test.ts b/src/timezone.test.ts index df0525f..1003a61 100644 --- a/src/timezone.test.ts +++ b/src/timezone.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { formatLocalTime } from './timezone.js'; +import { + formatLocalTime, + isValidTimezone, + resolveTimezone, +} from './timezone.js'; // --- formatLocalTime --- @@ -26,4 +30,44 @@ describe('formatLocalTime', () => { expect(ny).toContain('8:00'); expect(tokyo).toContain('9:00'); }); + + it('does not throw on invalid timezone, falls back to UTC', () => { + expect(() => + formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'), + ).not.toThrow(); + const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2'); + // Should format as UTC (noon UTC = 12:00 PM) + expect(result).toContain('12:00'); + expect(result).toContain('PM'); + }); +}); + +describe('isValidTimezone', () => { + it('accepts valid IANA identifiers', () => { + expect(isValidTimezone('America/New_York')).toBe(true); + expect(isValidTimezone('UTC')).toBe(true); + expect(isValidTimezone('Asia/Tokyo')).toBe(true); + expect(isValidTimezone('Asia/Jerusalem')).toBe(true); + }); + + it('rejects invalid timezone strings', () => { + expect(isValidTimezone('IST-2')).toBe(false); + expect(isValidTimezone('XYZ+3')).toBe(false); + }); + + it('rejects empty and garbage strings', () => { + expect(isValidTimezone('')).toBe(false); + expect(isValidTimezone('NotATimezone')).toBe(false); + }); +}); + +describe('resolveTimezone', () => { + it('returns the timezone if valid', () => { + expect(resolveTimezone('America/New_York')).toBe('America/New_York'); + }); + + it('falls back to UTC for invalid timezone', () => { + expect(resolveTimezone('IST-2')).toBe('UTC'); + expect(resolveTimezone('')).toBe('UTC'); + }); }); diff --git a/src/timezone.ts b/src/timezone.ts index e7569f4..d8cc6cc 100644 --- a/src/timezone.ts +++ b/src/timezone.ts @@ -1,11 +1,32 @@ +/** + * Check whether a timezone string is a valid IANA identifier + * that Intl.DateTimeFormat can use. + */ +export function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * Return the given timezone if valid IANA, otherwise fall back to UTC. + */ +export function resolveTimezone(tz: string): string { + return isValidTimezone(tz) ? tz : 'UTC'; +} + /** * Convert a UTC ISO timestamp to a localized display string. * Uses the Intl API (no external dependencies). + * Falls back to UTC if the timezone is invalid. */ export function formatLocalTime(utcIso: string, timezone: string): string { const date = new Date(utcIso); return date.toLocaleString('en-US', { - timeZone: timezone, + timeZone: resolveTimezone(timezone), year: 'numeric', month: 'short', day: 'numeric', From 6d4e25153476cbb51595939f40bef1677ebc55b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 23:05:15 +0000 Subject: [PATCH 053/124] chore: bump version to 1.2.25 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f17016..50d6563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index b710e94..8589bbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.24", + "version": "1.2.25", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From f375dd5011df20e992d4be2555538714b7c9610a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 23:05:19 +0000 Subject: [PATCH 054/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.4k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fedb84a..93aeb17 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.2k tokens, 21% of context window + + 42.4k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 42.2k + + 42.4k From 5d5b90448c558c5409d2448ee6e23f7621d30cf8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 23:05:58 +0000 Subject: [PATCH 055/124] chore: bump version to 1.2.26 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50d6563..1074356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.25", + "version": "1.2.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.25", + "version": "1.2.26", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index 8589bbd..c476b1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.25", + "version": "1.2.26", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From aeabfcc65a065d69bf59a56dade373d82442a911 Mon Sep 17 00:00:00 2001 From: nanoclaw3 Date: Wed, 25 Mar 2026 03:48:08 +0000 Subject: [PATCH 056/124] fix: enable loginctl linger so user service survives SSH logout Without linger enabled, systemd terminates all user-level processes (including the NanoClaw service) when the last SSH session closes. This adds `loginctl enable-linger` during setup for non-root users. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/service.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup/service.ts b/setup/service.ts index 71b3c63..c385267 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -266,6 +266,20 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Kill orphaned nanoclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); + // Enable lingering so the user service survives SSH logout. + // Without linger, systemd terminates all user processes when the last session closes. + if (!runningAsRoot) { + try { + execSync('loginctl enable-linger', { stdio: 'ignore' }); + logger.info('Enabled loginctl linger for current user'); + } catch (err) { + logger.warn( + { err }, + 'loginctl enable-linger failed — service may stop on SSH logout', + ); + } + } + // Enable and start try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); @@ -301,6 +315,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; UNIT_PATH: unitPath, SERVICE_LOADED: serviceLoaded, ...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}), + LINGER_ENABLED: !runningAsRoot, STATUS: 'success', LOG: 'logs/setup.log', }); From 2c46d74066c671b18cce04db551d65276f59d0a1 Mon Sep 17 00:00:00 2001 From: ingyukoh Date: Wed, 25 Mar 2026 15:21:44 +0900 Subject: [PATCH 057/124] fix: clarify WhatsApp phone number prompt to prevent auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example "1234567890" was ambiguous — users couldn't tell where the country code ended and the number began, and some included a leading "+" which caused pairing to fail. Use a realistic US example (14155551234) and explicit formatting rules in both the prompt and troubleshooting. Closes #447 --- .claude/skills/add-whatsapp/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 0774799..cbdf00b 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -40,7 +40,7 @@ Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to If they chose pairing code: -AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890) +AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.) ## Phase 2: Apply Code Changes @@ -308,7 +308,7 @@ rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone Date: Wed, 25 Mar 2026 16:17:26 +0900 Subject: [PATCH 058/124] fix: create CLAUDE.md from template when registering groups via IPC The registerGroup() function in index.ts creates the group folder and logs subdirectory but never copies the global CLAUDE.md template. Agents in newly registered groups start without identity or instructions until the container is manually fixed. Copy groups/global/CLAUDE.md into the new group folder on registration, substituting the assistant name if it differs from the default. Closes #1391 --- src/index.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3f5e710..1465d56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { ASSISTANT_NAME, + GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, POLL_INTERVAL, @@ -133,6 +134,25 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + // Copy CLAUDE.md template into the new group folder so agents have + // identity and instructions from the first run. (Fixes #1391) + const groupMdFile = path.join(groupDir, 'CLAUDE.md'); + if (!fs.existsSync(groupMdFile)) { + const templateFile = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); + if (fs.existsSync(templateFile)) { + let content = fs.readFileSync(templateFile, 'utf-8'); + if (ASSISTANT_NAME !== 'Andy') { + content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); + content = content.replace( + /You are Andy/g, + `You are ${ASSISTANT_NAME}`, + ); + } + fs.writeFileSync(groupMdFile, content); + logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); + } + } + // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) ensureOneCLIAgent(jid, group); From 63f680d0be3c7e68ab640a9e8ea1d8eed68e9e7d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 13:22:36 +0200 Subject: [PATCH 059/124] chore: remove grammy and pin better-sqlite3/cron-parser versions Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 91 +++-------------------------------------------- package.json | 7 ++-- 2 files changed, 7 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50d6563..37379df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,8 @@ "version": "1.2.25", "dependencies": { "@onecli-sh/sdk": "^0.2.0", - "better-sqlite3": "^11.8.1", - "cron-parser": "^5.5.0", - "grammy": "^1.39.3", + "better-sqlite3": "11.10.0", + "cron-parser": "5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -696,12 +695,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@grammyjs/types": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", - "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1609,18 +1602,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1911,6 +1892,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2198,15 +2180,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2391,21 +2364,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/grammy": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", - "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", - "license": "MIT", - "dependencies": { - "@grammyjs/types": "3.25.0", - "abort-controller": "^3.0.0", - "debug": "^4.4.3", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2756,6 +2714,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2801,26 +2760,6 @@ "node": ">=10" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3543,12 +3482,6 @@ "node": ">=14.0.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3817,22 +3750,6 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 8589bbd..8a6361e 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,12 @@ }, "dependencies": { "@onecli-sh/sdk": "^0.2.0", - "better-sqlite3": "^11.8.1", - "cron-parser": "^5.5.0", + "better-sqlite3": "11.10.0", + "cron-parser": "5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", - "zod": "^4.3.6", - "grammy": "^1.39.3" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.35.0", From 675a6d87a322c318cd476fd47f7a0bca05dca8d1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 13:25:58 +0200 Subject: [PATCH 060/124] chore: remove accidentally merged Telegram channel code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/index.ts | 1 - src/channels/telegram.test.ts | 949 ---------------------------------- src/channels/telegram.ts | 304 ----------- 3 files changed, 1254 deletions(-) delete mode 100644 src/channels/telegram.test.ts delete mode 100644 src/channels/telegram.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index 48356db..44f4f55 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -8,6 +8,5 @@ // slack // telegram -import './telegram.js'; // whatsapp diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts deleted file mode 100644 index 538c87b..0000000 --- a/src/channels/telegram.test.ts +++ /dev/null @@ -1,949 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -// Mock env reader (used by the factory, not needed in unit tests) -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); - -// Mock config -vi.mock('../config.js', () => ({ - ASSISTANT_NAME: 'Andy', - TRIGGER_PATTERN: /^@Andy\b/i, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// --- Grammy mock --- - -type Handler = (...args: any[]) => any; - -const botRef = vi.hoisted(() => ({ current: null as any })); - -vi.mock('grammy', () => ({ - Bot: class MockBot { - token: string; - commandHandlers = new Map(); - filterHandlers = new Map(); - errorHandler: Handler | null = null; - - api = { - sendMessage: vi.fn().mockResolvedValue(undefined), - sendChatAction: vi.fn().mockResolvedValue(undefined), - }; - - constructor(token: string) { - this.token = token; - botRef.current = this; - } - - command(name: string, handler: Handler) { - this.commandHandlers.set(name, handler); - } - - on(filter: string, handler: Handler) { - const existing = this.filterHandlers.get(filter) || []; - existing.push(handler); - this.filterHandlers.set(filter, existing); - } - - catch(handler: Handler) { - this.errorHandler = handler; - } - - start(opts: { onStart: (botInfo: any) => void }) { - opts.onStart({ username: 'andy_ai_bot', id: 12345 }); - } - - stop() {} - }, -})); - -import { TelegramChannel, TelegramChannelOpts } from './telegram.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): TelegramChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'tg:100200300': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function createTextCtx(overrides: { - chatId?: number; - chatType?: string; - chatTitle?: string; - text: string; - fromId?: number; - firstName?: string; - username?: string; - messageId?: number; - date?: number; - entities?: any[]; -}) { - const chatId = overrides.chatId ?? 100200300; - const chatType = overrides.chatType ?? 'group'; - return { - chat: { - id: chatId, - type: chatType, - title: overrides.chatTitle ?? 'Test Group', - }, - from: { - id: overrides.fromId ?? 99001, - first_name: overrides.firstName ?? 'Alice', - username: overrides.username ?? 'alice_user', - }, - message: { - text: overrides.text, - date: overrides.date ?? Math.floor(Date.now() / 1000), - message_id: overrides.messageId ?? 1, - entities: overrides.entities ?? [], - }, - me: { username: 'andy_ai_bot' }, - reply: vi.fn(), - }; -} - -function createMediaCtx(overrides: { - chatId?: number; - chatType?: string; - fromId?: number; - firstName?: string; - date?: number; - messageId?: number; - caption?: string; - extra?: Record; -}) { - const chatId = overrides.chatId ?? 100200300; - return { - chat: { - id: chatId, - type: overrides.chatType ?? 'group', - title: 'Test Group', - }, - from: { - id: overrides.fromId ?? 99001, - first_name: overrides.firstName ?? 'Alice', - username: 'alice_user', - }, - message: { - date: overrides.date ?? Math.floor(Date.now() / 1000), - message_id: overrides.messageId ?? 1, - caption: overrides.caption, - ...(overrides.extra || {}), - }, - me: { username: 'andy_ai_bot' }, - }; -} - -function currentBot() { - return botRef.current; -} - -async function triggerTextMessage(ctx: ReturnType) { - const handlers = currentBot().filterHandlers.get('message:text') || []; - for (const h of handlers) await h(ctx); -} - -async function triggerMediaMessage( - filter: string, - ctx: ReturnType, -) { - const handlers = currentBot().filterHandlers.get(filter) || []; - for (const h of handlers) await h(ctx); -} - -// --- Tests --- - -describe('TelegramChannel', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when bot starts', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(channel.isConnected()).toBe(true); - }); - - it('registers command and message handlers on connect', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(currentBot().commandHandlers.has('chatid')).toBe(true); - expect(currentBot().commandHandlers.has('ping')).toBe(true); - expect(currentBot().filterHandlers.has('message:text')).toBe(true); - expect(currentBot().filterHandlers.has('message:photo')).toBe(true); - expect(currentBot().filterHandlers.has('message:video')).toBe(true); - expect(currentBot().filterHandlers.has('message:voice')).toBe(true); - expect(currentBot().filterHandlers.has('message:audio')).toBe(true); - expect(currentBot().filterHandlers.has('message:document')).toBe(true); - expect(currentBot().filterHandlers.has('message:sticker')).toBe(true); - expect(currentBot().filterHandlers.has('message:location')).toBe(true); - expect(currentBot().filterHandlers.has('message:contact')).toBe(true); - }); - - it('registers error handler on connect', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(currentBot().errorHandler).not.toBeNull(); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - expect(channel.isConnected()).toBe(true); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - - it('isConnected() returns false before connect', () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - expect(channel.isConnected()).toBe(false); - }); - }); - - // --- Text message handling --- - - describe('text message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hello everyone' }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Test Group', - 'telegram', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - id: '1', - chat_jid: 'tg:100200300', - sender: '99001', - sender_name: 'Alice', - content: 'Hello everyone', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:999999', - expect.any(String), - 'Test Group', - 'telegram', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('skips bot commands (/chatid, /ping) but passes other / messages through', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - // Bot commands should be skipped - const ctx1 = createTextCtx({ text: '/chatid' }); - await triggerTextMessage(ctx1); - expect(opts.onMessage).not.toHaveBeenCalled(); - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - - const ctx2 = createTextCtx({ text: '/ping' }); - await triggerTextMessage(ctx2); - expect(opts.onMessage).not.toHaveBeenCalled(); - - // Non-bot /commands should flow through - const ctx3 = createTextCtx({ text: '/remote-control' }); - await triggerTextMessage(ctx3); - expect(opts.onMessage).toHaveBeenCalledTimes(1); - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '/remote-control' }), - ); - }); - - it('extracts sender name from first_name', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: 'Bob' }), - ); - }); - - it('falls back to username when first_name missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi' }); - ctx.from.first_name = undefined as any; - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: 'alice_user' }), - ); - }); - - it('falls back to user ID when name and username missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi', fromId: 42 }); - ctx.from.first_name = undefined as any; - ctx.from.username = undefined as any; - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: '42' }), - ); - }); - - it('uses sender name as chat name for private chats', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - 'tg:100200300': { - name: 'Private', - folder: 'private', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'Hello', - chatType: 'private', - firstName: 'Alice', - }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Alice', // Private chats use sender name - 'telegram', - false, - ); - }); - - it('uses chat title as name for group chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'Hello', - chatType: 'supergroup', - chatTitle: 'Project Team', - }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Project Team', - 'telegram', - true, - ); - }); - - it('converts message.date to ISO timestamp', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z - const ctx = createTextCtx({ text: 'Hello', date: unixTime }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - timestamp: '2024-01-01T00:00:00.000Z', - }), - ); - }); - }); - - // --- @mention translation --- - - describe('@mention translation', () => { - it('translates @bot_username mention to trigger format', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@andy_ai_bot what time is it?', - entities: [{ type: 'mention', offset: 0, length: 12 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy @andy_ai_bot what time is it?', - }), - ); - }); - - it('does not translate if message already matches trigger', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@Andy @andy_ai_bot hello', - entities: [{ type: 'mention', offset: 6, length: 12 }], - }); - await triggerTextMessage(ctx); - - // Should NOT double-prepend — already starts with @Andy - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy @andy_ai_bot hello', - }), - ); - }); - - it('does not translate mentions of other bots', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@some_other_bot hi', - entities: [{ type: 'mention', offset: 0, length: 15 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@some_other_bot hi', // No translation - }), - ); - }); - - it('handles mention in middle of message', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'hey @andy_ai_bot check this', - entities: [{ type: 'mention', offset: 4, length: 12 }], - }); - await triggerTextMessage(ctx); - - // Bot is mentioned, message doesn't match trigger → prepend trigger - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy hey @andy_ai_bot check this', - }), - ); - }); - - it('handles message with no entities', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'plain message' }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: 'plain message', - }), - ); - }); - - it('ignores non-mention entities', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'check https://example.com', - entities: [{ type: 'url', offset: 6, length: 19 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: 'check https://example.com', - }), - ); - }); - }); - - // --- Non-text messages --- - - describe('non-text messages', () => { - it('stores photo with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Photo]' }), - ); - }); - - it('stores photo with caption', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ caption: 'Look at this' }); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Photo] Look at this' }), - ); - }); - - it('stores video with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:video', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Video]' }), - ); - }); - - it('stores voice message with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:voice', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Voice message]' }), - ); - }); - - it('stores audio with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:audio', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Audio]' }), - ); - }); - - it('stores document with filename', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ - extra: { document: { file_name: 'report.pdf' } }, - }); - await triggerMediaMessage('message:document', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Document: report.pdf]' }), - ); - }); - - it('stores document with fallback name when filename missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ extra: { document: {} } }); - await triggerMediaMessage('message:document', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Document: file]' }), - ); - }); - - it('stores sticker with emoji', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ - extra: { sticker: { emoji: '😂' } }, - }); - await triggerMediaMessage('message:sticker', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Sticker 😂]' }), - ); - }); - - it('stores location with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:location', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Location]' }), - ); - }); - - it('stores contact with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:contact', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Contact]' }), - ); - }); - - it('ignores non-text messages from unregistered chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ chatId: 999999 }); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - }); - - // --- sendMessage --- - - describe('sendMessage', () => { - it('sends message via bot API', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('tg:100200300', 'Hello'); - - expect(currentBot().api.sendMessage).toHaveBeenCalledWith( - '100200300', - 'Hello', - { parse_mode: 'Markdown' }, - ); - }); - - it('strips tg: prefix from JID', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('tg:-1001234567890', 'Group message'); - - expect(currentBot().api.sendMessage).toHaveBeenCalledWith( - '-1001234567890', - 'Group message', - { parse_mode: 'Markdown' }, - ); - }); - - it('splits messages exceeding 4096 characters', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const longText = 'x'.repeat(5000); - await channel.sendMessage('tg:100200300', longText); - - expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2); - expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( - 1, - '100200300', - 'x'.repeat(4096), - { parse_mode: 'Markdown' }, - ); - expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( - 2, - '100200300', - 'x'.repeat(904), - { parse_mode: 'Markdown' }, - ); - }); - - it('sends exactly one message at 4096 characters', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const exactText = 'y'.repeat(4096); - await channel.sendMessage('tg:100200300', exactText); - - expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1); - }); - - it('handles send failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - currentBot().api.sendMessage.mockRejectedValueOnce( - new Error('Network error'), - ); - - // Should not throw - await expect( - channel.sendMessage('tg:100200300', 'Will fail'), - ).resolves.toBeUndefined(); - }); - - it('does nothing when bot is not initialized', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - // Don't connect — bot is null - await channel.sendMessage('tg:100200300', 'No bot'); - - // No error, no API call - }); - }); - - // --- ownsJid --- - - describe('ownsJid', () => { - it('owns tg: JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:123456')).toBe(true); - }); - - it('owns tg: JIDs with negative IDs (groups)', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:-1001234567890')).toBe(true); - }); - - it('does not own WhatsApp group JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(false); - }); - - it('does not own WhatsApp DM JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing action when isTyping is true', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('tg:100200300', true); - - expect(currentBot().api.sendChatAction).toHaveBeenCalledWith( - '100200300', - 'typing', - ); - }); - - it('does nothing when isTyping is false', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('tg:100200300', false); - - expect(currentBot().api.sendChatAction).not.toHaveBeenCalled(); - }); - - it('does nothing when bot is not initialized', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - // Don't connect - await channel.setTyping('tg:100200300', true); - - // No error, no API call - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - currentBot().api.sendChatAction.mockRejectedValueOnce( - new Error('Rate limited'), - ); - - await expect( - channel.setTyping('tg:100200300', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Bot commands --- - - describe('bot commands', () => { - it('/chatid replies with chat ID and metadata', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('chatid')!; - const ctx = { - chat: { id: 100200300, type: 'group' as const }, - from: { first_name: 'Alice' }, - reply: vi.fn(), - }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith( - expect.stringContaining('tg:100200300'), - expect.objectContaining({ parse_mode: 'Markdown' }), - ); - }); - - it('/chatid shows chat type', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('chatid')!; - const ctx = { - chat: { id: 555, type: 'private' as const }, - from: { first_name: 'Bob' }, - reply: vi.fn(), - }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith( - expect.stringContaining('private'), - expect.any(Object), - ); - }); - - it('/ping replies with bot status', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('ping')!; - const ctx = { reply: vi.fn() }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith('Andy is online.'); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "telegram"', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.name).toBe('telegram'); - }); - }); -}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts deleted file mode 100644 index effca6e..0000000 --- a/src/channels/telegram.ts +++ /dev/null @@ -1,304 +0,0 @@ -import https from 'https'; -import { Api, Bot } from 'grammy'; - -import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; -import { readEnvFile } from '../env.js'; -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnChatMetadata, - OnInboundMessage, - RegisteredGroup, -} from '../types.js'; - -export interface TelegramChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -/** - * Send a message with Telegram Markdown parse mode, falling back to plain text. - * Claude's output naturally matches Telegram's Markdown v1 format: - * *bold*, _italic_, `code`, ```code blocks```, [links](url) - */ -async function sendTelegramMessage( - api: { sendMessage: Api['sendMessage'] }, - chatId: string | number, - text: string, - options: { message_thread_id?: number } = {}, -): Promise { - try { - await api.sendMessage(chatId, text, { - ...options, - parse_mode: 'Markdown', - }); - } catch (err) { - // Fallback: send as plain text if Markdown parsing fails - logger.debug({ err }, 'Markdown send failed, falling back to plain text'); - await api.sendMessage(chatId, text, options); - } -} - -export class TelegramChannel implements Channel { - name = 'telegram'; - - private bot: Bot | null = null; - private opts: TelegramChannelOpts; - private botToken: string; - - constructor(botToken: string, opts: TelegramChannelOpts) { - this.botToken = botToken; - this.opts = opts; - } - - async connect(): Promise { - this.bot = new Bot(this.botToken, { - client: { - baseFetchConfig: { agent: https.globalAgent, compress: true }, - }, - }); - - // Command to get chat ID (useful for registration) - this.bot.command('chatid', (ctx) => { - const chatId = ctx.chat.id; - const chatType = ctx.chat.type; - const chatName = - chatType === 'private' - ? ctx.from?.first_name || 'Private' - : (ctx.chat as any).title || 'Unknown'; - - ctx.reply( - `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`, - { parse_mode: 'Markdown' }, - ); - }); - - // Command to check bot status - this.bot.command('ping', (ctx) => { - ctx.reply(`${ASSISTANT_NAME} is online.`); - }); - - // Telegram bot commands handled above — skip them in the general handler - // so they don't also get stored as messages. All other /commands flow through. - const TELEGRAM_BOT_COMMANDS = new Set(['chatid', 'ping']); - - this.bot.on('message:text', async (ctx) => { - if (ctx.message.text.startsWith('/')) { - const cmd = ctx.message.text.slice(1).split(/[\s@]/)[0].toLowerCase(); - if (TELEGRAM_BOT_COMMANDS.has(cmd)) return; - } - - const chatJid = `tg:${ctx.chat.id}`; - let content = ctx.message.text; - const timestamp = new Date(ctx.message.date * 1000).toISOString(); - const senderName = - ctx.from?.first_name || - ctx.from?.username || - ctx.from?.id.toString() || - 'Unknown'; - const sender = ctx.from?.id.toString() || ''; - const msgId = ctx.message.message_id.toString(); - - // Determine chat name - const chatName = - ctx.chat.type === 'private' - ? senderName - : (ctx.chat as any).title || chatJid; - - // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. - // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN - // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned. - const botUsername = ctx.me?.username?.toLowerCase(); - if (botUsername) { - const entities = ctx.message.entities || []; - const isBotMentioned = entities.some((entity) => { - if (entity.type === 'mention') { - const mentionText = content - .substring(entity.offset, entity.offset + entity.length) - .toLowerCase(); - return mentionText === `@${botUsername}`; - } - return false; - }); - if (isBotMentioned && !TRIGGER_PATTERN.test(content)) { - content = `@${ASSISTANT_NAME} ${content}`; - } - } - - // Store chat metadata for discovery - const isGroup = - ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata( - chatJid, - timestamp, - chatName, - 'telegram', - isGroup, - ); - - // Only deliver full message for registered groups - const group = this.opts.registeredGroups()[chatJid]; - if (!group) { - logger.debug( - { chatJid, chatName }, - 'Message from unregistered Telegram chat', - ); - return; - } - - // Deliver message — startMessageLoop() will pick it up - this.opts.onMessage(chatJid, { - id: msgId, - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: false, - }); - - logger.info( - { chatJid, chatName, sender: senderName }, - 'Telegram message stored', - ); - }); - - // Handle non-text messages with placeholders so the agent knows something was sent - const storeNonText = (ctx: any, placeholder: string) => { - const chatJid = `tg:${ctx.chat.id}`; - const group = this.opts.registeredGroups()[chatJid]; - if (!group) return; - - const timestamp = new Date(ctx.message.date * 1000).toISOString(); - const senderName = - ctx.from?.first_name || - ctx.from?.username || - ctx.from?.id?.toString() || - 'Unknown'; - const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; - - const isGroup = - ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'telegram', - isGroup, - ); - this.opts.onMessage(chatJid, { - id: ctx.message.message_id.toString(), - chat_jid: chatJid, - sender: ctx.from?.id?.toString() || '', - sender_name: senderName, - content: `${placeholder}${caption}`, - timestamp, - is_from_me: false, - }); - }; - - this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); - this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); - this.bot.on('message:voice', (ctx) => storeNonText(ctx, '[Voice message]')); - this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); - this.bot.on('message:document', (ctx) => { - const name = ctx.message.document?.file_name || 'file'; - storeNonText(ctx, `[Document: ${name}]`); - }); - this.bot.on('message:sticker', (ctx) => { - const emoji = ctx.message.sticker?.emoji || ''; - storeNonText(ctx, `[Sticker ${emoji}]`); - }); - this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]')); - this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]')); - - // Handle errors gracefully - this.bot.catch((err) => { - logger.error({ err: err.message }, 'Telegram bot error'); - }); - - // Start polling — returns a Promise that resolves when started - return new Promise((resolve) => { - this.bot!.start({ - onStart: (botInfo) => { - logger.info( - { username: botInfo.username, id: botInfo.id }, - 'Telegram bot connected', - ); - console.log(`\n Telegram bot: @${botInfo.username}`); - console.log( - ` Send /chatid to the bot to get a chat's registration ID\n`, - ); - resolve(); - }, - }); - }); - } - - async sendMessage(jid: string, text: string): Promise { - if (!this.bot) { - logger.warn('Telegram bot not initialized'); - return; - } - - try { - const numericId = jid.replace(/^tg:/, ''); - - // Telegram has a 4096 character limit per message — split if needed - const MAX_LENGTH = 4096; - if (text.length <= MAX_LENGTH) { - await sendTelegramMessage(this.bot.api, numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await sendTelegramMessage( - this.bot.api, - numericId, - text.slice(i, i + MAX_LENGTH), - ); - } - } - logger.info({ jid, length: text.length }, 'Telegram message sent'); - } catch (err) { - logger.error({ jid, err }, 'Failed to send Telegram message'); - } - } - - isConnected(): boolean { - return this.bot !== null; - } - - ownsJid(jid: string): boolean { - return jid.startsWith('tg:'); - } - - async disconnect(): Promise { - if (this.bot) { - this.bot.stop(); - this.bot = null; - logger.info('Telegram bot stopped'); - } - } - - async setTyping(jid: string, isTyping: boolean): Promise { - if (!this.bot || !isTyping) return; - try { - const numericId = jid.replace(/^tg:/, ''); - await this.bot.api.sendChatAction(numericId, 'typing'); - } catch (err) { - logger.debug({ jid, err }, 'Failed to send Telegram typing indicator'); - } - } -} - -registerChannel('telegram', (opts: ChannelOpts) => { - const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']); - const token = - process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || ''; - if (!token) { - logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set'); - return null; - } - return new TelegramChannel(token, opts); -}); From 093530a4180b7186e117011bed5852ad4a49d8c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 11:26:17 +0000 Subject: [PATCH 061/124] chore: bump version to 1.2.27 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4910b4f..1e0a7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.26", + "version": "1.2.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 83aa994..91746b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.26", + "version": "1.2.27", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 6e5834ee3cb7543578e165946a908deee43b5b97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 11:26:22 +0000 Subject: [PATCH 062/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?0.1k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 93aeb17..301a593 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.4k tokens, 21% of context window + + 40.1k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 42.4k + + 40.1k From d622a79fe24aa10079a3bd39c5e777a45a9f0f8d Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 11:41:25 +0000 Subject: [PATCH 063/124] fix: suppress spurious chat message on script skip When a script returns wakeAgent=false, set result to null so the host doesn't forward an internal status string to the user's chat. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 382439f..25554f9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -566,7 +566,7 @@ async function main(): Promise { log(`Script decided not to wake agent: ${reason}`); writeOutput({ status: 'success', - result: `Script: ${reason}`, + result: null, }); return; } From d4073a01c579fbdcfd699817135353ad15b2afc9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 14:08:47 +0200 Subject: [PATCH 064/124] chore: remove auto-sync GitHub Actions These workflows auto-resolved package.json conflicts with --theirs, silently stripping fork-specific dependencies during upstream syncs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/fork-sync-skills.yml | 256 --------------------- .github/workflows/merge-forward-skills.yml | 179 -------------- 2 files changed, 435 deletions(-) delete mode 100644 .github/workflows/fork-sync-skills.yml delete mode 100644 .github/workflows/merge-forward-skills.yml diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml deleted file mode 100644 index 4191695..0000000 --- a/.github/workflows/fork-sync-skills.yml +++ /dev/null @@ -1,256 +0,0 @@ -name: Sync upstream & merge-forward skill branches - -on: - # Triggered by upstream repo via repository_dispatch - repository_dispatch: - types: [upstream-main-updated] - # Fallback: run on a schedule in case dispatch isn't configured - schedule: - - cron: '0 */6 * * *' # every 6 hours - # Also run when fork's main is pushed directly - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - issues: write - -concurrency: - group: fork-sync - cancel-in-progress: true - -jobs: - sync-and-merge: - if: github.repository != 'qwibitai/nanoclaw' - runs-on: ubuntu-latest - steps: - - uses: actions/create-github-app-token@v1 - id: app-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Sync with upstream main - id: sync - run: | - # Add upstream remote - git remote add upstream https://github.com/qwibitai/nanoclaw.git - git fetch upstream main - - # Check if upstream has new commits - if git merge-base --is-ancestor upstream/main HEAD; then - echo "Already up to date with upstream main." - echo "synced=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Merge upstream main into fork's main - if ! git merge upstream/main --no-edit; then - # Auto-resolve trivial conflicts (lockfile, badge, package.json version) - CONFLICTED=$(git diff --name-only --diff-filter=U) - AUTO_RESOLVABLE=true - for f in $CONFLICTED; do - case "$f" in - package-lock.json|package.json|repo-tokens/badge.svg|.github/workflows/*) - git checkout --theirs "$f" - git add "$f" - ;; - .env.example) - # Keep fork's channel-specific env vars - git checkout --ours "$f" - git add "$f" - ;; - *) - AUTO_RESOLVABLE=false - ;; - esac - done - - if [ "$AUTO_RESOLVABLE" = false ]; then - echo "::error::Failed to merge upstream/main into fork main — non-trivial conflicts detected" - git merge --abort - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git commit --no-edit - echo "Auto-resolved lockfile/badge/version conflicts" - fi - - # Regenerate lockfile to match merged package.json - npm ci - if ! npm run build; then - echo "::error::Build failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! npm test 2>/dev/null; then - echo "::error::Tests failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git push origin main - echo "synced=true" >> "$GITHUB_OUTPUT" - - - name: Merge main into skill branches - id: merge - run: | - # Re-fetch to pick up any changes pushed since job start - git fetch origin - - FAILED="" - SUCCEEDED="" - - # List all remote skill branches - SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) - - if [ -z "$SKILL_BRANCHES" ]; then - echo "No skill branches found." - exit 0 - fi - - for BRANCH in $SKILL_BRANCHES; do - SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') - echo "" - echo "=== Processing $BRANCH ===" - - git checkout -B "$BRANCH" "origin/$BRANCH" - - if ! git merge main --no-edit; then - # Auto-resolve trivial conflicts - CONFLICTED=$(git diff --name-only --diff-filter=U) - CAN_AUTO=true - for f in $CONFLICTED; do - case "$f" in - package-lock.json|package.json|repo-tokens/badge.svg) - git checkout --theirs "$f" - git add "$f" - ;; - *) - CAN_AUTO=false - ;; - esac - done - if [ "$CAN_AUTO" = false ]; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue - fi - git commit --no-edit - fi - - # Check if there's anything new to push - if git diff --quiet "origin/$BRANCH"; then - echo "$BRANCH is already up to date with main." - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - continue - fi - - npm ci - - if ! npm run build; then - echo "::warning::Build failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - if ! npm test 2>/dev/null; then - echo "::warning::Tests failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - git push origin "$BRANCH" - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - echo "$BRANCH merged and pushed successfully." - done - - echo "" - echo "=== Results ===" - echo "Succeeded: $SUCCEEDED" - echo "Failed: $FAILED" - - echo "failed=$FAILED" >> "$GITHUB_OUTPUT" - echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" - - - name: Open issue for upstream sync failure - if: steps.sync.outputs.sync_failed == 'true' - uses: actions/github-script@v7 - with: - script: | - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Upstream sync failed — merge conflict or build failure`, - body: [ - 'The automated sync with `qwibitai/nanoclaw` main failed.', - '', - 'This usually means upstream made changes that conflict with this fork\'s channel code.', - '', - 'To resolve manually:', - '```bash', - 'git fetch upstream main', - 'git merge upstream/main', - '# resolve conflicts', - 'npm run build && npm test', - 'git push', - '```', - ].join('\n'), - labels: ['upstream-sync'] - }); - - - name: Open issue for failed skill merges - if: steps.merge.outputs.failed != '' - uses: actions/github-script@v7 - with: - script: | - const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); - const body = [ - `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, - '', - ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), - '', - 'Please resolve manually:', - '```bash', - ...failed.map(s => [ - `git checkout skill/${s}`, - `git merge main`, - `# resolve conflicts, then: git push`, - '' - ]).flat(), - '```', - ].join('\n'); - - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Merge-forward failed for ${failed.length} skill branch(es)`, - body, - labels: ['skill-maintenance'] - }); \ No newline at end of file diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml deleted file mode 100644 index 82471b0..0000000 --- a/.github/workflows/merge-forward-skills.yml +++ /dev/null @@ -1,179 +0,0 @@ -name: Merge-forward skill branches - -on: - push: - branches: [main] - -permissions: - contents: write - issues: write - -jobs: - merge-forward: - if: github.repository == 'qwibitai/nanoclaw' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Merge main into each skill branch - id: merge - run: | - FAILED="" - SUCCEEDED="" - - # List all remote skill branches - SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) - - if [ -z "$SKILL_BRANCHES" ]; then - echo "No skill branches found." - exit 0 - fi - - for BRANCH in $SKILL_BRANCHES; do - SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') - echo "" - echo "=== Processing $BRANCH ===" - - # Checkout the skill branch - git checkout -B "$BRANCH" "origin/$BRANCH" - - # Attempt merge - if ! git merge main --no-edit; then - # Auto-resolve trivial conflicts - CONFLICTED=$(git diff --name-only --diff-filter=U) - CAN_AUTO=true - for f in $CONFLICTED; do - case "$f" in - package-lock.json|package.json|repo-tokens/badge.svg) - git checkout --theirs "$f" - git add "$f" - ;; - *) - CAN_AUTO=false - ;; - esac - done - if [ "$CAN_AUTO" = false ]; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue - fi - git commit --no-edit - fi - - # Check if there's anything new to push - if git diff --quiet "origin/$BRANCH"; then - echo "$BRANCH is already up to date with main." - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - continue - fi - - # Install deps and validate - npm ci - - if ! npm run build; then - echo "::warning::Build failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - if ! npm test 2>/dev/null; then - echo "::warning::Tests failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - # Push the updated branch - git push origin "$BRANCH" - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - echo "$BRANCH merged and pushed successfully." - done - - echo "" - echo "=== Results ===" - echo "Succeeded: $SUCCEEDED" - echo "Failed: $FAILED" - - # Export for issue creation - echo "failed=$FAILED" >> "$GITHUB_OUTPUT" - echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" - - - name: Open issue for failed merges - if: steps.merge.outputs.failed != '' - uses: actions/github-script@v7 - with: - script: | - const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); - const sha = context.sha.substring(0, 7); - const body = [ - `The merge-forward workflow failed to merge \`main\` (${sha}) into the following skill branches:`, - '', - ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), - '', - 'Please resolve manually:', - '```bash', - ...failed.map(s => [ - `git checkout skill/${s}`, - `git merge main`, - `# resolve conflicts, then: git push`, - '' - ]).flat(), - '```', - '', - `Triggered by push to main: ${context.sha}` - ].join('\n'); - - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Merge-forward failed for ${failed.length} skill branch(es) after ${sha}`, - body, - labels: ['skill-maintenance'] - }); - - - name: Notify channel forks - if: always() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.FORK_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const forks = [ - 'nanoclaw-whatsapp', - 'nanoclaw-telegram', - 'nanoclaw-discord', - 'nanoclaw-slack', - 'nanoclaw-gmail', - 'nanoclaw-docker-sandboxes', - 'nanoclaw-docker-sandbox', - 'nanoclaw-docker-sandbox-windows', - ]; - const sha = context.sha.substring(0, 7); - for (const repo of forks) { - try { - await github.rest.repos.createDispatchEvent({ - owner: 'qwibitai', - repo, - event_type: 'upstream-main-updated', - client_payload: { sha: context.sha }, - }); - console.log(`Notified ${repo}`); - } catch (e) { - console.log(`Failed to notify ${repo}: ${e.message}`); - } - } From 80f6fb2b9abd57a844243b4d58a6d0e573dcdf16 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Wed, 25 Mar 2026 13:37:01 +0000 Subject: [PATCH 065/124] style: fix prettier formatting in registerGroup template copy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1465d56..f78d8e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,10 +143,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); - content = content.replace( - /You are Andy/g, - `You are ${ASSISTANT_NAME}`, - ); + content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`); } fs.writeFileSync(groupMdFile, content); logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); From b7434b8a76b7fb33ba85348e0f5054e048bfbd87 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 14:07:59 +0000 Subject: [PATCH 066/124] fix: use explicit Read tool directive for diagnostics instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording ("Send diagnostics data by following ...") was too passive — Claude treated the backtick-quoted path as informational rather than an action, so the diagnostics file was never actually read and the PostHog prompt was silently skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/SKILL.md | 3 ++- .claude/skills/update-nanoclaw/SKILL.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index e12e0ea..54c3d2d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -287,4 +287,5 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## 9. Diagnostics -Send diagnostics data by following `.claude/skills/setup/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. +2. Follow every step in that file before completing setup. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 2207910..496d409 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -237,4 +237,5 @@ Tell the user: ## Diagnostics -Send diagnostics data by following `.claude/skills/update-nanoclaw/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`. +2. Follow every step in that file before finishing. From 0240f48751914a3ab648245fbaec7d4f38b7da76 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Wed, 25 Mar 2026 14:43:08 +0000 Subject: [PATCH 067/124] fix: use main template for isMain groups in runtime registration Main groups (e.g. telegram_main) should get the full main template with Admin Context section, not the minimal global template. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f78d8e9..b3746f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,7 +138,11 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // identity and instructions from the first run. (Fixes #1391) const groupMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(groupMdFile)) { - const templateFile = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); + const templateFile = path.join( + GROUPS_DIR, + group.isMain ? 'main' : 'global', + 'CLAUDE.md', + ); if (fs.existsSync(templateFile)) { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { From 31c03cf92406271a1c5866874ab8bb1a1489e14a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:45 +0000 Subject: [PATCH 068/124] chore: bump version to 1.2.28 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e0a7e3..0b699ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 91746b0..a7f6a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 9391304e7043c9c07a839d72219c7ffbe92a704e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:47 +0000 Subject: [PATCH 069/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?0.2k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 301a593..8c3b0c8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.1k tokens, 20% of context window + + 40.2k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.1k + + 40.2k From bb736f37f2ea8a23dab905c7d885492082ec2bf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:25 +0000 Subject: [PATCH 070/124] chore: bump version to 1.2.29 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b699ed..4128040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a7f6a5f..0822ed9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From df76dc6797807ee9702c56a30731086fca63dab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:27 +0000 Subject: [PATCH 071/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?1.0k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 8c3b0c8..be808ed 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.2k tokens, 20% of context window + + 41.0k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.2k + + 41.0k From fd444681ef571f13a37153a4b80d6cd6a2f6b19f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:36:23 +0000 Subject: [PATCH 072/124] chore: bump version to 1.2.30 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4128040..68f9244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 0822ed9..3ceb71f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From b8f6a9b794a043e6bd9a0be496322d85a5c62eb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:36:26 +0000 Subject: [PATCH 073/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?1.2k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index be808ed..50f3af8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.0k tokens, 20% of context window + + 41.2k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.0k + + 41.2k From 6d4f972ad02aba57a799dcf49c4160f2d1f2c0c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:37:53 +0000 Subject: [PATCH 074/124] chore: bump version to 1.2.31 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 68f9244..9cd9fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 3ceb71f..056e931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 7bba21af1e71422618891e1995f08ad4825eb504 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 22:01:54 +0200 Subject: [PATCH 075/124] feat(skill): add channel-formatting skill Adds SKILL.md for channel-aware text formatting. When applied, converts Claude's Markdown output to each channel's native syntax (WhatsApp, Telegram, Slack) before delivery. Source code lives on the skill/channel-formatting branch. Co-Authored-By: Ken Bolton Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/channel-formatting/SKILL.md | 137 +++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .claude/skills/channel-formatting/SKILL.md diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md new file mode 100644 index 0000000..b995fb8 --- /dev/null +++ b/.claude/skills/channel-formatting/SKILL.md @@ -0,0 +1,137 @@ +--- +name: channel-formatting +description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill. +--- + +# Channel Formatting + +This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's +responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or +Telegram. + +| Channel | Transformation | +|---------|---------------| +| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links flattened | +| Telegram | same as WhatsApp | +| Slack | same as WhatsApp, but links become `` | +| Discord | passthrough (Discord already renders Markdown) | +| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | + +Code blocks (fenced and inline) are always protected — their content is never transformed. + +## Phase 1: Pre-flight + +### Check if already applied + +```bash +test -f src/text-styles.ts && echo "already applied" || echo "not yet applied" +``` + +If `already applied`, skip to Phase 3 (Verify). + +## Phase 2: Apply Code Changes + +### Ensure the upstream remote + +```bash +git remote -v +``` + +If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, +add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/channel-formatting +git merge upstream/skill/channel-formatting +``` + +If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming +version and continuing: + +```bash +git checkout --theirs package-lock.json +git add package-lock.json +git merge --continue +``` + +For any other conflict, read the conflicted file and reconcile both sides manually. + +This merge adds: + +- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and + `parseSignalStyles(text)` for Signal native rich text +- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided + it calls `parseTextStyles` after stripping `` tags +- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound` +- `src/formatting.test.ts` — test coverage for both functions across all channels + +### Validate + +```bash +npm install +npm run build +npx vitest run src/formatting.test.ts +``` + +All 73 tests should pass and the build should be clean before continuing. + +## Phase 3: Verify + +### Rebuild and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +### Spot-check formatting + +Send a message through any registered WhatsApp or Telegram chat that will trigger a +response from Claude. Ask something that will produce formatted output, such as: + +> Summarise the three main advantages of TypeScript using bullet points and **bold** headings. + +Confirm that the response arrives with native bold (`*text*`) rather than raw double +asterisks. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log +``` + +## Signal Skill Integration + +If you have the Signal skill installed, `src/channels/signal.ts` can import +`parseSignalStyles` from the newly present `src/text-styles.ts`: + +```typescript +import { parseSignalStyles, SignalTextStyle } from '../text-styles.js'; +``` + +`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where +`textStyle` is an array of `{ style, start, length }` objects suitable for the +`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`). + +## Removal + +```bash +# Remove the new file +rm src/text-styles.ts + +# Revert router.ts to remove the channel param +git diff upstream/main src/router.ts # review changes +git checkout upstream/main -- src/router.ts + +# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText) +# (edit manually or: git checkout upstream/main -- src/index.ts) + +npm run build +``` \ No newline at end of file From 1f36232ef0e8b35018c0ac2e2318c23837b94be5 Mon Sep 17 00:00:00 2001 From: flobo3 Date: Wed, 25 Mar 2026 22:25:00 +0200 Subject: [PATCH 076/124] docs: add flobo3 to contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1d4a5de..ca7f3cb 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,3 +13,4 @@ Thanks to everyone who has contributed to NanoClaw! - [baijunjie](https://github.com/baijunjie) — BaiJunjie - [Michaelliv](https://github.com/Michaelliv) — Michael - [kk17](https://github.com/kk17) — Kyle Zhike Chen +- [flobo3](https://github.com/flobo3) — Flo From 3a26f69c7fa558a402c248f016435a8fd64410b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 20:39:06 +0000 Subject: [PATCH 077/124] chore: bump version to 1.2.32 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cd9fae..987b285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 056e931..0095817 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 7bfd060536a183a667d8e5f65286ec7157a0ac92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 20:47:41 +0000 Subject: [PATCH 078/124] chore: bump version to 1.2.33 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 987b285..e8033ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.32", + "version": "1.2.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.32", + "version": "1.2.33", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 0095817..dfa9afa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.32", + "version": "1.2.33", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From deb5389077534db268bf2ee900502c61fd57c66b Mon Sep 17 00:00:00 2001 From: Ken Bolton Date: Wed, 25 Mar 2026 16:52:29 -0400 Subject: [PATCH 079/124] fix(skill/channel-formatting): correct Telegram link behaviour in SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram Markdown v1 renders [text](url) links natively — they are now preserved rather than flattened to "text (url)". Update the skill table to reflect the actual post-fix behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/channel-formatting/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md index b995fb8..3e2334c 100644 --- a/.claude/skills/channel-formatting/SKILL.md +++ b/.claude/skills/channel-formatting/SKILL.md @@ -11,8 +11,8 @@ Telegram. | Channel | Transformation | |---------|---------------| -| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links flattened | -| Telegram | same as WhatsApp | +| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links → `text (url)` | +| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) | | Slack | same as WhatsApp, but links become `` | | Discord | passthrough (Discord already renders Markdown) | | Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | From 68c59a1abfffc647849b3201513ff17e970532c9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 23:09:33 +0200 Subject: [PATCH 080/124] feat(skill): add Emacs channel skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SKILL.md for the Emacs channel — an HTTP bridge that lets Emacs send messages to NanoClaw and poll for responses. Source code lives on the skill/emacs branch. Co-Authored-By: Ken Bolton Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-emacs/SKILL.md | 289 ++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 .claude/skills/add-emacs/SKILL.md diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md new file mode 100644 index 0000000..09bdbdd --- /dev/null +++ b/.claude/skills/add-emacs/SKILL.md @@ -0,0 +1,289 @@ +--- +name: add-emacs +description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed. +--- + +# Add Emacs Channel + +This skill adds Emacs support to NanoClaw, then walks through interactive setup. +Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. + +## What you can do with this + +- **Ask while coding** — open the chat buffer (`C-c n c` / `SPC N c`), ask about a function or error without leaving Emacs +- **Code review** — select a region and send it with `nanoclaw-org-send`; the response appears as a child heading inline in your org file +- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node +- **Draft writing** — send org prose; receive revisions or continuations in place +- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it +- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR") + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `src/channels/emacs.ts` exists: + +```bash +test -f src/channels/emacs.ts && echo "already applied" || echo "not applied" +``` + +If it exists, skip to Phase 3 (Setup). The code changes are already in place. + +## Phase 2: Apply Code Changes + +### Ensure the upstream remote + +```bash +git remote -v +``` + +If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, +add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/emacs +git merge upstream/skill/emacs +``` + +If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming +version and continuing: + +```bash +git checkout --theirs package-lock.json +git add package-lock.json +git merge --continue +``` + +For any other conflict, read the conflicted file and reconcile both sides manually. + +This adds: +- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766) +- `src/channels/emacs.test.ts` — unit tests +- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`) +- `import './emacs.js'` appended to `src/channels/index.ts` + +If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. + +### Validate code changes + +```bash +npm run build +npx vitest run src/channels/emacs.test.ts +``` + +Build must be clean and tests must pass before proceeding. + +## Phase 3: Setup + +### Configure environment (optional) + +The channel works out of the box with defaults. Add to `.env` only if you need non-defaults: + +```bash +EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use +EMACS_AUTH_TOKEN= # optional — locks the endpoint to Emacs only +``` + +If you change or add values, sync to the container environment: + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +### Configure Emacs + +The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed. + +AskUserQuestion: Which Emacs distribution are you using? +- **Doom Emacs** - config.el with map! keybindings +- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs +- **Vanilla Emacs / other** - init.el with global-set-key + +**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`): + +```elisp +;; NanoClaw — personal AI assistant channel +(load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el")) + +(map! :leader + :prefix ("N" . "NanoClaw") + :desc "Chat buffer" "c" #'nanoclaw-chat + :desc "Send org" "o" #'nanoclaw-org-send) +``` + +Then reload: `M-x doom/reload` + +**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`: + +```elisp +;; NanoClaw — personal AI assistant channel +(load-file "~/src/nanoclaw/emacs/nanoclaw.el") + +(spacemacs/set-leader-keys "aNc" #'nanoclaw-chat) +(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send) +``` + +Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. + +**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`): + +```elisp +;; NanoClaw — personal AI assistant channel +(load-file "~/src/nanoclaw/emacs/nanoclaw.el") + +(global-set-key (kbd "C-c n c") #'nanoclaw-chat) +(global-set-key (kbd "C-c n o") #'nanoclaw-org-send) +``` + +Then reload: `M-x eval-buffer` or restart Emacs. + +If `EMACS_AUTH_TOKEN` was set, also add (any distribution): + +```elisp +(setq nanoclaw-auth-token "") +``` + +If `EMACS_CHANNEL_PORT` was changed from the default, also add: + +```elisp +(setq nanoclaw-port ) +``` + +### Restart NanoClaw + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +## Phase 4: Verify + +### Test the HTTP endpoint + +```bash +curl -s "http://localhost:8766/api/messages?since=0" +``` + +Expected: `{"messages":[]}` + +If you set `EMACS_AUTH_TOKEN`: + +```bash +curl -s -H "Authorization: Bearer " "http://localhost:8766/api/messages?since=0" +``` + +### Test from Emacs + +Tell the user: + +> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`) +> 2. Type a message and press `RET` +> 3. A response from Andy should appear within a few seconds +> +> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o` + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log +``` + +Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent. + +## Troubleshooting + +### Port already in use + +``` +Error: listen EADDRINUSE: address already in use :::8766 +``` + +Either a stale NanoClaw process is running, or 8766 is taken by another app. + +Find and kill the stale process: + +```bash +lsof -ti :8766 | xargs kill -9 +``` + +Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config. + +### No response from agent + +Check: +1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) +2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"` +3. Logs show activity: `tail -50 logs/nanoclaw.log` + +If the group is not registered, it will be created automatically on the next NanoClaw restart. + +### Auth token mismatch (401 Unauthorized) + +Verify the token in Emacs matches `.env`: + +```elisp +;; M-x describe-variable RET nanoclaw-auth-token RET +``` + +Must exactly match `EMACS_AUTH_TOKEN` in `.env`. + +### nanoclaw.el not loading + +Check the path is correct: + +```bash +ls ~/src/nanoclaw/emacs/nanoclaw.el +``` + +If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config. + +## After Setup + +If running `npm run dev` while the service is active: + +```bash +# macOS: +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +npm run dev +# When done testing: +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# Linux: +# systemctl --user stop nanoclaw +# npm run dev +# systemctl --user start nanoclaw +``` + +## Agent Formatting + +The Emacs bridge converts markdown → org-mode automatically. Agents should +output standard markdown — **not** org-mode syntax. The conversion handles: + +| Markdown | Org-mode | +|----------|----------| +| `**bold**` | `*bold*` | +| `*italic*` | `/italic/` | +| `~~text~~` | `+text+` | +| `` `code` `` | `~code~` | +| ` ```lang ` | `#+begin_src lang` | + +If an agent outputs org-mode directly, bold/italic/etc. will be double-converted +and render incorrectly. + +## Removal + +To remove the Emacs channel: + +1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el` +2. Remove `import './emacs.js'` from `src/channels/index.ts` +3. Remove the NanoClaw block from your Emacs config file +4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"` +5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set +6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) \ No newline at end of file From 125757bc7d2b7326ea412dc5dadb0673bc5be937 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 21:29:02 +0000 Subject: [PATCH 081/124] chore: bump version to 1.2.34 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8033ad..2c69d40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.33", + "version": "1.2.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.33", + "version": "1.2.34", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index dfa9afa..0d76ee5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.33", + "version": "1.2.34", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 2cddefbef4616b5cde41afbe954fbd81f4c059e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 21:29:08 +0000 Subject: [PATCH 082/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?1.3k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 50f3af8..58e9bb3 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.2k tokens, 21% of context window + + 41.3k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.2k + + 41.3k From 2c447085b5de65069d4f7d895312948c67b65969 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 23:41:24 +0200 Subject: [PATCH 083/124] chore: add edwinwzhe to contributors Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ca7f3cb..143392b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -14,3 +14,4 @@ Thanks to everyone who has contributed to NanoClaw! - [Michaelliv](https://github.com/Michaelliv) — Michael - [kk17](https://github.com/kk17) — Kyle Zhike Chen - [flobo3](https://github.com/flobo3) — Flo +- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He From 9413ace113b2a1f1c7cfcea91cb3697b99333861 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 23:43:54 +0200 Subject: [PATCH 084/124] chore: add edwinwzhe and scottgl9 to contributors Co-Authored-By: Edwin He Co-Authored-By: Scott Glover --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 143392b..4038595 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,3 +15,4 @@ Thanks to everyone who has contributed to NanoClaw! - [kk17](https://github.com/kk17) — Kyle Zhike Chen - [flobo3](https://github.com/flobo3) — Flo - [edwinwzhe](https://github.com/edwinwzhe) — Edwin He +- [scottgl9](https://github.com/scottgl9) — Scott Glover From 349b54ae9ef8a8d2991394632fce224cae63eae0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 23:54:05 +0200 Subject: [PATCH 085/124] fix(add-statusbar): derive log path from binary location, fix SKILL.md - statusbar.swift: derive project root from binary location instead of hardcoding ~/Documents/Projects/nanoclaw - SKILL.md: remove references to non-existent apply-skill.ts, compile directly from skill directory using ${CLAUDE_SKILL_DIR} - SKILL.md: add xattr -cr step for Gatekeeper on macOS Sequoia+ - Remove unused manifest.yaml Co-Authored-By: tomermesser Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-statusbar/SKILL.md | 51 ++++++++----------- .../add-statusbar/add/src/statusbar.swift | 10 +++- .claude/skills/add-statusbar/manifest.yaml | 10 ---- 3 files changed, 31 insertions(+), 40 deletions(-) delete mode 100644 .claude/skills/add-statusbar/manifest.yaml diff --git a/.claude/skills/add-statusbar/SKILL.md b/.claude/skills/add-statusbar/SKILL.md index c0f343c..07012bf 100644 --- a/.claude/skills/add-statusbar/SKILL.md +++ b/.claude/skills/add-statusbar/SKILL.md @@ -1,11 +1,12 @@ --- name: add-statusbar -description: Add a macOS menu bar status indicator for NanoClaw. Shows a ⚡ icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only. +description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only. --- # Add macOS Menu Bar Status Indicator -Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar. +Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user +start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar. **macOS only.** Requires Xcode Command Line Tools (`swiftc`). @@ -39,45 +40,38 @@ If not found, tell the user: launchctl list | grep com.nanoclaw.statusbar ``` -If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 4 (Verify). +If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify). -## Phase 2: Apply Code Changes - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-statusbar -``` - -This copies `src/statusbar.swift` into the project and records the application in `.nanoclaw/state.yaml`. - -## Phase 3: Compile and Install +## Phase 2: Compile and Install ### Compile the Swift binary +The source lives in the skill directory. Compile it into `dist/`: + ```bash -swiftc -O -o dist/statusbar src/statusbar.swift +mkdir -p dist +swiftc -O -o dist/statusbar "${CLAUDE_SKILL_DIR}/add/src/statusbar.swift" ``` -This produces a small (~55KB) native binary at `dist/statusbar`. +This produces a small native binary at `dist/statusbar`. + +On macOS Sequoia or later, clear the quarantine attribute so the binary can run: + +```bash +xattr -cr dist/statusbar +``` ### Create the launchd plist -Determine the absolute project root: +Determine the absolute project root and home directory: ```bash pwd +echo $HOME ``` -Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values for `{PROJECT_ROOT}` and `{HOME}`: +Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values +for `{PROJECT_ROOT}` and `{HOME}`: ```xml @@ -113,7 +107,7 @@ Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the a launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist ``` -## Phase 4: Verify +## Phase 3: Verify ```bash launchctl list | grep com.nanoclaw.statusbar @@ -123,7 +117,7 @@ The first column should show a PID (not `-`). Tell the user: -> The ⚡ icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service. +> The bolt icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service. > > - **Green dot** — NanoClaw is running > - **Red dot** — NanoClaw is stopped @@ -136,5 +130,4 @@ Tell the user: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist rm dist/statusbar -rm src/statusbar.swift ``` diff --git a/.claude/skills/add-statusbar/add/src/statusbar.swift b/.claude/skills/add-statusbar/add/src/statusbar.swift index 6fff79a..2577380 100644 --- a/.claude/skills/add-statusbar/add/src/statusbar.swift +++ b/.claude/skills/add-statusbar/add/src/statusbar.swift @@ -7,6 +7,14 @@ class StatusBarController: NSObject { private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist" + /// Derive the NanoClaw project root from the binary location. + /// The binary is compiled to {project}/dist/statusbar, so the parent of + /// the parent directory is the project root. + private static let projectRoot: String = { + let binary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() + return binary.deletingLastPathComponent().deletingLastPathComponent().path + }() + override init() { super.init() setupStatusItem() @@ -108,7 +116,7 @@ class StatusBarController: NSObject { } @objc private func viewLogs() { - let logPath = "\(NSHomeDirectory())/Documents/Projects/nanoclaw/logs/nanoclaw.log" + let logPath = "\(StatusBarController.projectRoot)/logs/nanoclaw.log" NSWorkspace.shared.open(URL(fileURLWithPath: logPath)) } diff --git a/.claude/skills/add-statusbar/manifest.yaml b/.claude/skills/add-statusbar/manifest.yaml deleted file mode 100644 index 0d7d720..0000000 --- a/.claude/skills/add-statusbar/manifest.yaml +++ /dev/null @@ -1,10 +0,0 @@ -skill: statusbar -version: 1.0.0 -description: "macOS menu bar status indicator — shows NanoClaw running state with start/stop/restart controls" -core_version: 0.1.0 -adds: - - src/statusbar.swift -modifies: [] -structured: {} -conflicts: [] -depends: [] From e4f15b659e0ba0a0769431fcaa46be86da6aaa0e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 23:55:21 +0200 Subject: [PATCH 086/124] rename skill to add-macos-statusbar Co-Authored-By: tomermesser Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/{add-statusbar => add-macos-statusbar}/SKILL.md | 4 ++-- .../add/src/statusbar.swift | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename .claude/skills/{add-statusbar => add-macos-statusbar}/SKILL.md (98%) rename .claude/skills/{add-statusbar => add-macos-statusbar}/add/src/statusbar.swift (100%) diff --git a/.claude/skills/add-statusbar/SKILL.md b/.claude/skills/add-macos-statusbar/SKILL.md similarity index 98% rename from .claude/skills/add-statusbar/SKILL.md rename to .claude/skills/add-macos-statusbar/SKILL.md index 07012bf..62855f2 100644 --- a/.claude/skills/add-statusbar/SKILL.md +++ b/.claude/skills/add-macos-statusbar/SKILL.md @@ -1,5 +1,5 @@ --- -name: add-statusbar +name: add-macos-statusbar description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only. --- @@ -32,7 +32,7 @@ If not found, tell the user: > xcode-select --install > ``` > -> Then re-run `/add-statusbar`. +> Then re-run `/add-macos-statusbar`. ### Check if already installed diff --git a/.claude/skills/add-statusbar/add/src/statusbar.swift b/.claude/skills/add-macos-statusbar/add/src/statusbar.swift similarity index 100% rename from .claude/skills/add-statusbar/add/src/statusbar.swift rename to .claude/skills/add-macos-statusbar/add/src/statusbar.swift From 4c6d9241d4b5eb8fe8b953d5c14e9a87f874e20c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 26 Mar 2026 13:25:18 +0200 Subject: [PATCH 087/124] docs: update README and security docs to reflect OneCLI Agent Vault adoption Replace references to the old built-in credential proxy with OneCLI's Agent Vault across README (feature list, FAQ) and docs/SECURITY.md (credential isolation section, architecture diagram). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 ++- docs/SECURITY.md | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8d1eb37..874a8d7 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication, - **Scheduled tasks** - Recurring jobs that run Claude and can message you back - **Web access** - Search and fetch content from the Web - **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS) +- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits. - **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks - **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills @@ -160,7 +161,7 @@ Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via W **Is this secure?** -Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. +Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. Credentials never enter the container — outbound API requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects authentication at the proxy level and supports rate limits and access policies. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. **Why no configuration files?** diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3562fbd..7cf29f8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -64,20 +64,22 @@ Messages and task operations are verified against group identity: | View all tasks | ✓ | Own only | | Manage other groups | ✓ | ✗ | -### 5. Credential Isolation (Credential Proxy) +### 5. Credential Isolation (OneCLI Agent Vault) -Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently. +Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level. **How it works:** -1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001) -2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:` and `ANTHROPIC_API_KEY=placeholder` -3. The SDK sends API requests to the proxy with the placeholder key -4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com` -5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` +1. Credentials are registered once with `onecli secrets create`, stored and managed by OneCLI +2. When NanoClaw spawns a container, it calls `applyContainerConfig()` to route outbound HTTPS through the OneCLI gateway +3. The gateway matches requests by host and path, injects the real credential, and forwards +4. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` + +**Per-agent policies:** +Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap. **NOT Mounted:** -- Channel auth sessions (`store/auth/`) - host only -- Mount allowlist - external, never mounted +- Channel auth sessions (`store/auth/`) — host only +- Mount allowlist — external, never mounted - Any credentials matching blocked patterns - `.env` is shadowed with `/dev/null` in the project root mount @@ -107,7 +109,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP │ • IPC authorization │ │ • Mount validation (external allowlist) │ │ • Container lifecycle │ -│ • Credential proxy (injects auth headers) │ +│ • OneCLI Agent Vault (injects credentials, enforces policies) │ └────────────────────────────────┬─────────────────────────────────┘ │ ▼ Explicit mounts only, no secrets @@ -116,7 +118,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP │ • Agent execution │ │ • Bash commands (sandboxed) │ │ • File operations (limited to mounts) │ -│ • API calls routed through credential proxy │ +│ • API calls routed through OneCLI Agent Vault │ │ • No real credentials in environment or filesystem │ └──────────────────────────────────────────────────────────────────┘ ``` From 8b53a95a5f1daf2fd465eed43b738a03aaee7c68 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 26 Mar 2026 13:31:31 +0200 Subject: [PATCH 088/124] feat: add /init-onecli skill for OneCLI Agent Vault setup and credential migration Operational skill that installs OneCLI, configures the Agent Vault gateway, and migrates existing .env credentials into the vault. Designed to run after /update-nanoclaw introduces OneCLI as a breaking change. Added [BREAKING] changelog entry so update-nanoclaw automatically offers to run /init-onecli. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/init-onecli/SKILL.md | 241 ++++++++++++++++++++++++++++ CHANGELOG.md | 4 + CLAUDE.md | 1 + 3 files changed, 246 insertions(+) create mode 100644 .claude/skills/init-onecli/SKILL.md diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md new file mode 100644 index 0000000..54856aa --- /dev/null +++ b/.claude/skills/init-onecli/SKILL.md @@ -0,0 +1,241 @@ +--- +name: init-onecli +description: Install and initialize OneCLI Agent Vault. Migrates existing .env credentials to the vault. Use after /update-nanoclaw brings in OneCLI as a breaking change, or for first-time OneCLI setup. +--- + +# Initialize OneCLI Agent Vault + +This skill installs OneCLI, configures the Agent Vault gateway, and migrates any existing `.env` credentials into it. Run this after `/update-nanoclaw` introduces OneCLI as a breaking change, or any time OneCLI needs to be set up from scratch. + +**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. pasting a token). + +## Phase 1: Pre-flight + +### Check if OneCLI is already working + +```bash +onecli version 2>/dev/null +``` + +If the command succeeds, OneCLI is installed. Check if the gateway is reachable: + +```bash +curl -sf http://127.0.0.1:10254/health +``` + +If both succeed, check for an Anthropic secret: + +```bash +onecli secrets list +``` + +If an Anthropic secret exists, tell the user OneCLI is already configured and working. Use AskUserQuestion: + +1. **Keep current setup** — description: "OneCLI is installed and has credentials configured. Nothing to do." +2. **Reconfigure** — description: "Start fresh — reinstall OneCLI and re-register credentials." + +If they choose to keep, skip to Phase 5 (Verify). If they choose to reconfigure, continue. + +### Check for native credential proxy + +```bash +grep "credential-proxy" src/index.ts 2>/dev/null +``` + +If `startCredentialProxy` is imported, the native credential proxy skill is active. Tell the user: "You're currently using the native credential proxy (`.env`-based). This skill will switch you to OneCLI's Agent Vault, which adds per-agent policies and rate limits. Your `.env` credentials will be migrated to the vault." + +Use AskUserQuestion: +1. **Continue** — description: "Switch to OneCLI Agent Vault." +2. **Cancel** — description: "Keep the native credential proxy." + +If they cancel, stop. + +### Check the codebase expects OneCLI + +```bash +grep "@onecli-sh/sdk" package.json +``` + +If `@onecli-sh/sdk` is NOT in package.json, the codebase hasn't been updated to use OneCLI yet. Tell the user to run `/update-nanoclaw` first to get the OneCLI integration, then retry `/init-onecli`. Stop here. + +## Phase 2: Install OneCLI + +### Install the gateway and CLI + +```bash +curl -fsSL onecli.sh/install | sh +curl -fsSL onecli.sh/cli/install | sh +``` + +Verify: `onecli version` + +If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH: + +```bash +export PATH="$HOME/.local/bin:$PATH" +grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +``` + +Re-verify with `onecli version`. + +### Configure the CLI + +Point the CLI at the local OneCLI instance: + +```bash +onecli config set api-host http://127.0.0.1:10254 +``` + +### Set ONECLI_URL in .env + +```bash +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env +``` + +### Wait for gateway readiness + +The gateway may take a moment to start after installation. Poll for up to 15 seconds: + +```bash +for i in $(seq 1 15); do + curl -sf http://127.0.0.1:10254/health && break + sleep 1 +done +``` + +If it never becomes healthy, check if the gateway process is running: + +```bash +ps aux | grep -i onecli | grep -v grep +``` + +If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation. + +## Phase 3: Migrate existing credentials + +### Scan .env for credentials to migrate + +Read the `.env` file and look for these credential variables: + +| .env variable | OneCLI secret type | Host pattern | +|---|---|---| +| `ANTHROPIC_API_KEY` | `anthropic` | `api.anthropic.com` | +| `CLAUDE_CODE_OAUTH_TOKEN` | `anthropic` | `api.anthropic.com` | +| `ANTHROPIC_AUTH_TOKEN` | `anthropic` | `api.anthropic.com` | + +Read `.env`: + +```bash +cat .env +``` + +Parse the file for any of the credential variables listed above. + +### If credentials found in .env + +For each credential found, migrate it to OneCLI: + +**Anthropic API key** (`ANTHROPIC_API_KEY=sk-ant-...`): +```bash +onecli secrets create --name Anthropic --type anthropic --value --host-pattern api.anthropic.com +``` + +**Claude OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN=...` or `ANTHROPIC_AUTH_TOKEN=...`): +```bash +onecli secrets create --name Anthropic --type anthropic --value --host-pattern api.anthropic.com +``` + +After successful migration, remove the credential lines from `.env`. Use the Edit tool to remove only the credential variable lines (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`). Keep all other `.env` entries intact (e.g. `ONECLI_URL`, `TELEGRAM_BOT_TOKEN`, channel tokens). + +Verify the secret was registered: +```bash +onecli secrets list +``` + +Tell the user: "Migrated your credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers." + +### If no credentials found in .env + +No migration needed. Proceed to register credentials fresh. + +Check if OneCLI already has an Anthropic secret: +```bash +onecli secrets list +``` + +If an Anthropic secret already exists, skip to Phase 4. + +Otherwise, register credentials using the same flow as `/setup`: + +AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? + +1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." +2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." + +#### Subscription path + +Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat. + +Once they have the token, AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" + +#### API key path + +Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. + +AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" + +#### After either path + +Ask them to let you know when done. + +**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): handle it gracefully — run the `onecli secrets create` command with that value on their behalf. + +**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. + +## Phase 4: Build and restart + +```bash +npm run build +``` + +If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `npm install` first. + +Restart the service: +- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` +- Linux (systemd): `systemctl --user restart nanoclaw` +- WSL/manual: stop and re-run `bash start-nanoclaw.sh` + +## Phase 5: Verify + +Check logs for successful OneCLI integration: + +```bash +tail -30 logs/nanoclaw.log | grep -i "onecli\|gateway" +``` + +Expected: `OneCLI gateway config applied` messages when containers start. + +If the service is running and a channel is configured, tell the user to send a test message to verify the agent responds. + +Tell the user: +- OneCLI Agent Vault is now managing credentials +- Agents never see raw API keys — credentials are injected at the gateway level +- To manage secrets: `onecli secrets list`, or open http://127.0.0.1:10254 +- To add rate limits or policies: `onecli rules create --help` + +## Troubleshooting + +**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf http://127.0.0.1:10254/health`. Start it with `onecli start` if needed. + +**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`). + +**Old .env credentials still present:** This skill should have removed them. Double-check `.env` for `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_AUTH_TOKEN` and remove them manually if still present. + +**Port 10254 already in use:** Another OneCLI instance may be running. Check with `lsof -i :10254` and kill the old process, or configure a different port. diff --git a/CHANGELOG.md b/CHANGELOG.md index 323c0e1..28178e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [1.2.35] - 2026-03-26 + +- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Existing `.env` credentials must be migrated to the vault. Run `/init-onecli` to install OneCLI and migrate credentials. + ## [1.2.21] - 2026-03-22 - Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again) diff --git a/CLAUDE.md b/CLAUDE.md index 2084578..c9c49ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) f | `/customize` | Adding channels, integrations, changing behavior | | `/debug` | Container issues, logs, troubleshooting | | `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install | +| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials to it | | `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | | `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | From d398ba5ac66a836664214e04761d7ea2aeffd86e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 26 Mar 2026 13:51:24 +0200 Subject: [PATCH 089/124] feat(init-onecli): offer to migrate non-Anthropic .env credentials to vault After migrating Anthropic credentials, the skill now scans .env for other service tokens (Telegram, Slack, Discord, OpenAI, etc.) and offers to move them into OneCLI Agent Vault as well. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/init-onecli/SKILL.md | 39 ++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index 54856aa..9111510 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -153,7 +153,44 @@ Verify the secret was registered: onecli secrets list ``` -Tell the user: "Migrated your credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers." +Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers." + +### Offer to migrate other service credentials + +After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for any remaining credential variables. Look for variables whose names contain `_TOKEN`, `_KEY`, `_SECRET`, or `_PASSWORD`, excluding non-credential entries like `ONECLI_URL` and other config values. + +Common examples from NanoClaw skills: + +| .env variable | Secret name | Host pattern | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | `Telegram` | `api.telegram.org` | +| `SLACK_BOT_TOKEN` | `Slack Bot` | `slack.com` | +| `SLACK_APP_TOKEN` | `Slack App` | `slack.com` | +| `DISCORD_BOT_TOKEN` | `Discord` | `discord.com` | +| `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` | +| `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` | + +If any such variables are found with non-empty values, present them to the user: + +AskUserQuestion (multiSelect): "These other credentials are still in `.env`. Would you like to move any of them to the OneCLI Agent Vault as well? Credentials in the vault are never exposed to containers and can have rate limits and policies applied." + +- One option per credential found (e.g., "TELEGRAM_BOT_TOKEN" — description: "Telegram bot token, will be proxied through the vault") +- **Skip — keep them in .env** — description: "Leave these credentials in .env for now. You can move them later." + +For each credential the user selects: + +```bash +onecli secrets create --name --type api_key --value --host-pattern +``` + +If a variable isn't in the table above, use a reasonable secret name derived from the variable name (e.g., `MY_SERVICE_KEY` becomes `My Service`) and ask the user what host pattern to use: "What API host does this credential authenticate against? (e.g., `api.example.com`)" + +After migration, remove the migrated lines from `.env` using the Edit tool. Keep any credentials the user chose not to migrate. + +Verify all secrets were registered: +```bash +onecli secrets list +``` ### If no credentials found in .env From a41746530fdf6f7cdaf13b08e92ba0e6873a6b98 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 26 Mar 2026 13:52:25 +0200 Subject: [PATCH 090/124] fix(init-onecli): only offer to migrate container-facing credentials Channel tokens (Telegram, Slack, Discord) are used by the host process, not by containers via the gateway. Only offer to migrate credentials that containers use for outbound API calls (OpenAI, Parallel, etc.). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/init-onecli/SKILL.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index 9111510..d7727dd 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -155,27 +155,25 @@ onecli secrets list Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers." -### Offer to migrate other service credentials +### Offer to migrate other container-facing credentials -After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for any remaining credential variables. Look for variables whose names contain `_TOKEN`, `_KEY`, `_SECRET`, or `_PASSWORD`, excluding non-credential entries like `ONECLI_URL` and other config values. +After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for remaining credential variables that containers use for outbound API calls. -Common examples from NanoClaw skills: +**Important:** Only migrate credentials that containers use via outbound HTTPS. Channel tokens (`TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DISCORD_BOT_TOKEN`) are used by the NanoClaw host process to connect to messaging platforms — they must stay in `.env`. + +Known container-facing credentials: | .env variable | Secret name | Host pattern | |---|---|---| -| `TELEGRAM_BOT_TOKEN` | `Telegram` | `api.telegram.org` | -| `SLACK_BOT_TOKEN` | `Slack Bot` | `slack.com` | -| `SLACK_APP_TOKEN` | `Slack App` | `slack.com` | -| `DISCORD_BOT_TOKEN` | `Discord` | `discord.com` | | `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` | | `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` | -If any such variables are found with non-empty values, present them to the user: +If any of these are found with non-empty values, present them to the user: -AskUserQuestion (multiSelect): "These other credentials are still in `.env`. Would you like to move any of them to the OneCLI Agent Vault as well? Credentials in the vault are never exposed to containers and can have rate limits and policies applied." +AskUserQuestion (multiSelect): "These credentials are used by container agents for outbound API calls. Moving them to the vault means agents never see the raw keys, and you can apply rate limits and policies." -- One option per credential found (e.g., "TELEGRAM_BOT_TOKEN" — description: "Telegram bot token, will be proxied through the vault") -- **Skip — keep them in .env** — description: "Leave these credentials in .env for now. You can move them later." +- One option per credential found (e.g., "OPENAI_API_KEY" — description: "Used by voice transcription and other OpenAI integrations inside containers") +- **Skip — keep them in .env** — description: "Leave these in .env for now. You can move them later." For each credential the user selects: @@ -183,9 +181,9 @@ For each credential the user selects: onecli secrets create --name --type api_key --value --host-pattern ``` -If a variable isn't in the table above, use a reasonable secret name derived from the variable name (e.g., `MY_SERVICE_KEY` becomes `My Service`) and ask the user what host pattern to use: "What API host does this credential authenticate against? (e.g., `api.example.com`)" +If there are credential variables not in the table above that look container-facing (i.e. not a channel token), ask the user: "Is `` used by agents inside containers? If so, what API host does it authenticate against? (e.g., `api.example.com`)" — then migrate accordingly. -After migration, remove the migrated lines from `.env` using the Edit tool. Keep any credentials the user chose not to migrate. +After migration, remove the migrated lines from `.env` using the Edit tool. Keep channel tokens and any credentials the user chose not to migrate. Verify all secrets were registered: ```bash From d25b79a5a97a750a09d610e91e355ca2c49abbb7 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Thu, 26 Mar 2026 13:17:07 +0000 Subject: [PATCH 091/124] docs: add auth credentials guidance to main group CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that only long-lived OAuth tokens (claude setup-token) or API keys should be used — short-lived tokens from the keychain expire within hours and cause recurring 401s. Also update native credential proxy skill to swap the OneCLI reference when applied. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/use-native-credential-proxy/SKILL.md | 10 ++++++++++ groups/main/CLAUDE.md | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/.claude/skills/use-native-credential-proxy/SKILL.md b/.claude/skills/use-native-credential-proxy/SKILL.md index 4cdda4c..71448b1 100644 --- a/.claude/skills/use-native-credential-proxy/SKILL.md +++ b/.claude/skills/use-native-credential-proxy/SKILL.md @@ -64,6 +64,16 @@ This merges in: If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides. +### Update main group CLAUDE.md + +Replace the OneCLI auth reference with the native proxy: + +In `groups/main/CLAUDE.md`, replace: +> OneCLI manages credentials (including Anthropic auth) — run `onecli --help`. + +with: +> The native credential proxy manages credentials (including Anthropic auth) via `.env` — see `src/credential-proxy.ts`. + ### Validate code changes ```bash diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 6080427..17b39cb 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -77,6 +77,10 @@ Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`. This is the **main channel**, which has elevated privileges. +## Authentication + +Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`. + ## Container Mounts Main has read-only access to the project and read-write access to its group folder: From 813e1c6fa4d9c170b1e8f748347cb3bb6e3c97e4 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Wed, 25 Mar 2026 22:05:29 +0000 Subject: [PATCH 092/124] fix: improve task scripts agent instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword Task Scripts opening in main template to guide agents toward schedule_task instead of inline bash loops. Add missing Task Scripts section to global template — non-main groups have unrestricted access to schedule_task with script parameter, so omitting instructions just leads to worse patterns. Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/global/CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ groups/main/CLAUDE.md | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index c814e39..7018c04 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -74,3 +74,42 @@ No `##` headings. No `[links](url)`. No `**double stars**`. ### Discord channels (folder starts with `discord_`) Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. + +--- + +## Task Scripts + +To check or monitor something on a recurring basis, use `schedule_task` — not a bash loop. This way the check survives container restarts and doesn't block other messages. If the user only needs to know when a condition changes, add a `script` to avoid unnecessary wake-ups — the script runs first, and you only wake up when there's something to act on. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 6080427..5e693fa 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -267,7 +267,7 @@ The task will run in that group's context with access to their files and memory. ## Task Scripts -When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up. +To check or monitor something on a recurring basis, use `schedule_task` — not a bash loop. This way the check survives container restarts and doesn't block other messages. If the user only needs to know when a condition changes, add a `script` to avoid unnecessary wake-ups — the script runs first, and you only wake up when there's something to act on. ### How it works From a29ca0835c37ede7ef490e21dda6a6a840bbe4a7 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Thu, 26 Mar 2026 11:21:18 +0000 Subject: [PATCH 093/124] fix: rewrite task scripts intro for broader use cases and clarity Broadens the trigger from "check or monitor" to "any recurring task", adds context about API credit usage and account risk for frequent tasks, and prompts the agent to clarify ambiguous requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/global/CLAUDE.md | 2 +- groups/main/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 7018c04..935578a 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -79,7 +79,7 @@ Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. ## Task Scripts -To check or monitor something on a recurring basis, use `schedule_task` — not a bash loop. This way the check survives container restarts and doesn't block other messages. If the user only needs to know when a condition changes, add a `script` to avoid unnecessary wake-ups — the script runs first, and you only wake up when there's something to act on. +For any recurring task, use `schedule_task`. Tasks that wake the agent frequently — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether you need to act, add a `script` — it runs first, and you only wake up when the check passes. This keeps agent invocations to a minimum. If it's unclear whether the user wants a response every time or only when something requires attention, ask. ### How it works diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 5e693fa..d3ea5f9 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -267,7 +267,7 @@ The task will run in that group's context with access to their files and memory. ## Task Scripts -To check or monitor something on a recurring basis, use `schedule_task` — not a bash loop. This way the check survives container restarts and doesn't block other messages. If the user only needs to know when a condition changes, add a `script` to avoid unnecessary wake-ups — the script runs first, and you only wake up when there's something to act on. +For any recurring task, use `schedule_task`. Tasks that wake the agent frequently — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether you need to act, add a `script` — it runs first, and you only wake up when the check passes. This keeps agent invocations to a minimum. If it's unclear whether the user wants a response every time or only when something requires attention, ask. ### How it works From eda14f472beaa3e7a94e773bdcafeeacc1612ec6 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Thu, 26 Mar 2026 12:37:28 +0000 Subject: [PATCH 094/124] fix: include script field in task snapshot for current_tasks.json The task snapshot mappings in index.ts were omitting the script field, making it appear that scheduled tasks had no script even when one was stored in the database. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 60fe910..bf57823 100644 --- a/src/index.ts +++ b/src/index.ts @@ -329,6 +329,7 @@ async function runAgent( id: t.id, groupFolder: t.group_folder, prompt: t.prompt, + script: t.script || undefined, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, @@ -685,6 +686,7 @@ async function main(): Promise { id: t.id, groupFolder: t.group_folder, prompt: t.prompt, + script: t.script || undefined, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, From 730ea0d713634edf1abcf16defe887b05a5accc0 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Thu, 26 Mar 2026 15:05:53 +0000 Subject: [PATCH 095/124] fix: refine task scripts intro wording Use third-person voice and clearer terminology for the task scripts intro paragraph. Co-Authored-By: Claude Opus 4.6 (1M context) --- groups/global/CLAUDE.md | 2 +- groups/main/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 935578a..11988bc 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -79,7 +79,7 @@ Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. ## Task Scripts -For any recurring task, use `schedule_task`. Tasks that wake the agent frequently — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether you need to act, add a `script` — it runs first, and you only wake up when the check passes. This keeps agent invocations to a minimum. If it's unclear whether the user wants a response every time or only when something requires attention, ask. +For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. ### How it works diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index d3ea5f9..c901813 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -267,7 +267,7 @@ The task will run in that group's context with access to their files and memory. ## Task Scripts -For any recurring task, use `schedule_task`. Tasks that wake the agent frequently — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether you need to act, add a `script` — it runs first, and you only wake up when the check passes. This keeps agent invocations to a minimum. If it's unclear whether the user wants a response every time or only when something requires attention, ask. +For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. ### How it works From 4383e3e61aeaad30ad3cac69ac9e377ac214f89a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Mar 2026 15:39:34 +0000 Subject: [PATCH 096/124] chore: bump version to 1.2.35 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c69d40..46a8742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.34", + "version": "1.2.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.34", + "version": "1.2.35", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 0d76ee5..fca2280 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.34", + "version": "1.2.35", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From a4fd4f2a2f0362612b344eda4178f3d230179f46 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Mar 2026 19:00:28 -0300 Subject: [PATCH 097/124] fix(security): prevent command injection in stopContainer and mount path injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **stopContainer (container-runtime.ts):** - Validate container name against `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$` before passing to shell command. Rejects names with shell metacharacters (`;`, `$()`, backticks, etc.) that could execute arbitrary commands. - Changed return type from string to void — callers no longer build shell commands from the return value. **mount-security.ts:** - Reject container paths containing `:` to prevent Docker `-v` option injection (e.g., `repo:rw` could override readonly flags). - Don't permanently cache "file not found" for mount allowlist — the file may be created later without requiring a service restart. Only parse/structural errors are permanently cached. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runtime.test.ts | 13 +++++++++++-- src/container-runtime.ts | 11 +++++++---- src/mount-security.ts | 8 +++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index d111bf6..fd43286 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -39,11 +39,20 @@ describe('readonlyMountArgs', () => { }); describe('stopContainer', () => { - it('returns stop command using CONTAINER_RUNTIME_BIN', () => { - expect(stopContainer('nanoclaw-test-123')).toBe( + it('calls docker stop for valid container names', () => { + stopContainer('nanoclaw-test-123'); + expect(mockExecSync).toHaveBeenCalledWith( `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, + { stdio: 'pipe' }, ); }); + + it('rejects names with shell metacharacters', () => { + expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name'); + expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name'); + expect(() => stopContainer('foo`id`')).toThrow('Invalid container name'); + expect(mockExecSync).not.toHaveBeenCalled(); + }); }); // --- ensureContainerRuntimeRunning --- diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 6326fde..beaedfa 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -27,9 +27,12 @@ export function readonlyMountArgs( return ['-v', `${hostPath}:${containerPath}:ro`]; } -/** Returns the shell command to stop a container by name. */ -export function stopContainer(name: string): string { - return `${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`; +/** Stop a container by name. Uses execFileSync to avoid shell injection. */ +export function stopContainer(name: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { + throw new Error(`Invalid container name: ${name}`); + } + execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' }); } /** Ensure the container runtime is running, starting it if needed. */ @@ -82,7 +85,7 @@ export function cleanupOrphans(): void { const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { - execSync(stopContainer(name), { stdio: 'pipe' }); + stopContainer(name); } catch { /* already stopped */ } diff --git a/src/mount-security.ts b/src/mount-security.ts index 3dceea5..e19c6bf 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -63,7 +63,8 @@ export function loadMountAllowlist(): MountAllowlist | null { try { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { - allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`; + // Do NOT cache this as an error — file may be created later without restart. + // Only parse/structural errors are permanently cached. logger.warn( { path: MOUNT_ALLOWLIST_PATH }, 'Mount allowlist not found - additional mounts will be BLOCKED. ' + @@ -215,6 +216,11 @@ function isValidContainerPath(containerPath: string): boolean { return false; } + // Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw") + if (containerPath.includes(':')) { + return false; + } + return true; } From 0f01fe2c07a37d5cf39069839b771735327392bc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Mar 2026 19:01:17 -0300 Subject: [PATCH 098/124] fix(env): prevent crash on single-character .env values A value like `X=a` would pass the startsWith/endsWith quote check (both `"` and `'` are single chars), then slice(1, -1) would produce an empty string, silently dropping the value. Add length >= 2 guard before checking for surrounding quotes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/env.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/env.ts b/src/env.ts index 988b59e..82cd5c3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -30,8 +30,9 @@ export function readEnvFile(keys: string[]): Record { if (!wanted.has(key)) continue; let value = trimmed.slice(eqIdx + 1).trim(); if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) ) { value = value.slice(1, -1); } From f5375972c409de17d94d7c5d00ae819f932d4a9d Mon Sep 17 00:00:00 2001 From: snw35 Date: Thu, 26 Mar 2026 23:20:30 +0000 Subject: [PATCH 099/124] Preserve isMain on IPC updates --- src/ipc.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ipc.ts b/src/ipc.ts index 043b07a..a454fdf 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -441,7 +441,10 @@ export async function processTaskIpc( ); break; } - // Defense in depth: agent cannot set isMain via IPC + // Defense in depth: agent cannot set isMain via IPC. + // Preserve isMain from the existing registration so IPC config + // updates (e.g. adding additionalMounts) don't strip the flag. + const existingGroup = registeredGroups[data.jid]; deps.registerGroup(data.jid, { name: data.name, folder: data.folder, @@ -449,6 +452,7 @@ export async function processTaskIpc( added_at: new Date().toISOString(), containerConfig: data.containerConfig, requiresTrigger: data.requiresTrigger, + isMain: existingGroup?.isMain, }); } else { logger.warn( From 8f01a9a05ee6e3adb61566afd5a57e2e014eaff6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 14:24:41 +0300 Subject: [PATCH 100/124] chore: remove unused dependencies (yaml, zod, @vitest/coverage-v8) None of these are imported or referenced by the main codebase. yaml had zero imports; zod is only used in container/agent-runner (which has its own package.json); coverage-v8 was never configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 222 +--------------------------------------------- package.json | 5 +- 2 files changed, 5 insertions(+), 222 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46a8742..cf59cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,15 +12,12 @@ "better-sqlite3": "11.10.0", "cron-parser": "5.5.0", "pino": "^9.6.0", - "pino-pretty": "^13.0.0", - "yaml": "^2.8.2", - "zod": "^4.3.6" + "pino-pretty": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.35.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.35.0", "eslint-plugin-no-catch-all": "^1.1.0", "globals": "^15.12.0", @@ -35,66 +32,6 @@ "node": ">=20" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -743,16 +680,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -760,17 +687,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@onecli-sh/sdk": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz", @@ -1460,37 +1376,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", - "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1670,18 +1555,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -2380,13 +2253,6 @@ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2496,45 +2362,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2544,13 +2371,6 @@ "node": ">=10" } }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2643,34 +2463,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -3801,7 +3593,10 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -3823,15 +3618,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index fca2280..d21ccd0 100644 --- a/package.json +++ b/package.json @@ -25,15 +25,12 @@ "better-sqlite3": "11.10.0", "cron-parser": "5.5.0", "pino": "^9.6.0", - "pino-pretty": "^13.0.0", - "yaml": "^2.8.2", - "zod": "^4.3.6" + "pino-pretty": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.35.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.35.0", "eslint-plugin-no-catch-all": "^1.1.0", "globals": "^15.12.0", From 2f472a8600a3f30d76311aac27c2620dd36981c8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 14:31:23 +0300 Subject: [PATCH 101/124] feat: add opt-in model management tools to ollama skill setup Update SKILL.md to ask users during setup whether they want model management tools (pull, delete, show, list-running) and set OLLAMA_ADMIN_TOOLS=true in .env accordingly. Core inference tools remain always available. Incorporates #1456 by @bitcryptic-gw. Closes #1331. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-ollama-tool/SKILL.md | 56 +++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..aa69295 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -1,15 +1,21 @@ --- name: add-ollama-tool -description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries. +description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library. --- # Add Ollama Integration -This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. +This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly. -Tools added: -- `ollama_list_models` — lists installed Ollama models -- `ollama_generate` — sends a prompt to a specified model and returns the response +Core tools (always available): +- `ollama_list_models` — list installed Ollama models with name, size, and family +- `ollama_generate` — send a prompt to a specified model and return the response + +Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`): +- `ollama_pull_model` — pull (download) a model from the Ollama registry +- `ollama_delete_model` — delete a locally installed model to free disk space +- `ollama_show_model` — show model details: modelfile, parameters, and architecture info +- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type ## Phase 1: Pre-flight @@ -89,6 +95,23 @@ Build must be clean before proceeding. ## Phase 3: Configure +### Enable model management tools (optional) + +Ask the user: + +> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)? +> +> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory +> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host) + +If the user wants management tools, add to `.env`: + +```bash +OLLAMA_ADMIN_TOOLS=true +``` + +If they decline (or don't answer), do not add the variable — management tools will be disabled by default. + ### Set Ollama host (optional) By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`: @@ -106,7 +129,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Phase 4: Verify -### Test via WhatsApp +### Test inference Tell the user: @@ -114,6 +137,14 @@ Tell the user: > > The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. +### Test model management (if enabled) + +If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user: + +> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?" +> +> The agent should call `ollama_pull_model` or `ollama_list_running` respectively. + ### Monitor activity (optional) Run the watcher script for macOS notifications when Ollama is used: @@ -129,9 +160,10 @@ tail -f logs/nanoclaw.log | grep -i ollama ``` Look for: -- `Agent output: ... Ollama ...` — agent used Ollama successfully -- `[OLLAMA] >>> Generating` — generation started (if log surfacing works) +- `[OLLAMA] >>> Generating` — generation started - `[OLLAMA] <<< Done` — generation completed +- `[OLLAMA] Pulling model:` — pull in progress (management tools) +- `[OLLAMA] Deleted:` — model removed (management tools) ## Troubleshooting @@ -151,3 +183,11 @@ The agent is trying to run `ollama` CLI inside the container instead of using th ### Agent doesn't use Ollama tools The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." + +### `ollama_pull_model` times out on large models + +Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull ` + +### Management tools not showing up + +Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. From 7b22e23761cb83eba12e3b7b25bfdf468b3ab692 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 15:13:00 +0300 Subject: [PATCH 102/124] chore: replace pino/pino-pretty with built-in logger Drop 23 transitive dependencies by replacing pino + pino-pretty with a ~70-line logger that matches the same output format and API. All 80+ call sites work unchanged. Production deps now: @onecli-sh/sdk, better-sqlite3, cron-parser. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 235 +----------------------------------------- package.json | 4 +- src/logger.ts | 73 +++++++++++-- src/mount-security.ts | 8 +- 4 files changed, 70 insertions(+), 250 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf59cbb..7888048 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,7 @@ "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", - "cron-parser": "5.5.0", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0" + "cron-parser": "5.5.0" }, "devDependencies": { "@eslint/js": "^9.35.0", @@ -695,12 +693,6 @@ "node": ">=20" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1555,15 +1547,6 @@ "node": ">=12" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1714,12 +1697,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1752,15 +1729,6 @@ "node": ">= 8" } }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2072,12 +2040,6 @@ "node": ">=12.0.0" } }, - "node_modules/fast-copy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", - "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2096,12 +2058,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2247,12 +2203,6 @@ "node": ">=8" } }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "license": "MIT" - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2362,15 +2312,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2563,15 +2504,6 @@ ], "license": "MIT" }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2685,76 +2617,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "license": "MIT", - "dependencies": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "fast-safe-stringify": "^2.1.1", - "help-me": "^5.0.0", - "joycon": "^3.1.1", - "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" - }, - "bin": { - "pino-pretty": "bin.js" - } - }, - "node_modules/pino-pretty/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2835,22 +2697,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -2870,12 +2716,6 @@ "node": ">=6" } }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2914,15 +2754,6 @@ "node": ">= 6" } }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3007,31 +2838,6 @@ ], "license": "MIT" }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3117,15 +2923,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3136,15 +2933,6 @@ "node": ">=0.10.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3168,18 +2956,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3221,15 +2997,6 @@ "node": ">=6" } }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index d21ccd0..a86e33a 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,7 @@ "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", - "cron-parser": "5.5.0", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0" + "cron-parser": "5.5.0" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/src/logger.ts b/src/logger.ts index 273dc0f..80cba30 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,11 +1,72 @@ -import pino from 'pino'; +const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const; +type Level = keyof typeof LEVELS; -export const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } }, -}); +const COLORS: Record = { + debug: '\x1b[34m', + info: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', + fatal: '\x1b[41m\x1b[37m', +}; +const KEY_COLOR = '\x1b[35m'; +const MSG_COLOR = '\x1b[36m'; +const RESET = '\x1b[39m'; +const FULL_RESET = '\x1b[0m'; -// Route uncaught errors through pino so they get timestamps in stderr +const threshold = + LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; + +function formatErr(err: unknown): string { + if (err instanceof Error) { + return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`; + } + return JSON.stringify(err); +} + +function formatData(data: Record): string { + let out = ''; + for (const [k, v] of Object.entries(data)) { + if (k === 'err') { + out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`; + } else { + out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`; + } + } + return out; +} + +function ts(): string { + const d = new Date(); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; +} + +function log(level: Level, dataOrMsg: Record | string, msg?: string): void { + if (LEVELS[level] < threshold) return; + const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; + const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; + if (typeof dataOrMsg === 'string') { + stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`); + } else { + stream.write( + `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`, + ); + } +} + +export const logger = { + debug: (dataOrMsg: Record | string, msg?: string) => + log('debug', dataOrMsg, msg), + info: (dataOrMsg: Record | string, msg?: string) => + log('info', dataOrMsg, msg), + warn: (dataOrMsg: Record | string, msg?: string) => + log('warn', dataOrMsg, msg), + error: (dataOrMsg: Record | string, msg?: string) => + log('error', dataOrMsg, msg), + fatal: (dataOrMsg: Record | string, msg?: string) => + log('fatal', dataOrMsg, msg), +}; + +// Route uncaught errors through logger so they get timestamps in stderr process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception'); process.exit(1); diff --git a/src/mount-security.ts b/src/mount-security.ts index 3dceea5..a724876 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -9,16 +9,10 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import pino from 'pino'; - import { MOUNT_ALLOWLIST_PATH } from './config.js'; +import { logger } from './logger.js'; import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js'; -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } }, -}); - // Cache the allowlist in memory - only reloads on process restart let cachedAllowlist: MountAllowlist | null = null; let allowlistLoadError: string | null = null; From 7e7492ebba9296d2d669a8982aab9e3432de3752 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 15:13:39 +0300 Subject: [PATCH 103/124] style: apply prettier formatting to logger Co-Authored-By: Claude Opus 4.6 (1M context) --- src/logger.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index 80cba30..6b18a9b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -40,12 +40,18 @@ function ts(): string { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } -function log(level: Level, dataOrMsg: Record | string, msg?: string): void { +function log( + level: Level, + dataOrMsg: Record | string, + msg?: string, +): void { if (LEVELS[level] < threshold) return; const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; if (typeof dataOrMsg === 'string') { - stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`); + stream.write( + `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`, + ); } else { stream.write( `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`, From 62fc8c770811066dae83784a286810a076cdb42d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 12:13:53 +0000 Subject: [PATCH 104/124] chore: bump version to 1.2.36 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7888048..b1dd2ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.35", + "version": "1.2.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.35", + "version": "1.2.36", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a86e33a..081d2b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.35", + "version": "1.2.36", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From f900670aaf91ff6cb219a6f6499475c12d3d5e81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 12:13:56 +0000 Subject: [PATCH 105/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.0k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 58e9bb3..6e1646a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.3k tokens, 21% of context window + + 42.0k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.3k + + 42.0k From 8935e4f636ced7c94b0a56a28affb4da3581e20f Mon Sep 17 00:00:00 2001 From: James Schindler Date: Fri, 27 Mar 2026 08:28:58 -0400 Subject: [PATCH 106/124] docs: add k8s image GC known issue to debug checklist Kubernetes image garbage collection silently deletes the nanoclaw-agent image when disk usage is high because ephemeral containers don't protect the image from GC. Documents symptoms, cause, fix, and diagnosis. --- docs/DEBUG_CHECKLIST.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md index 5597067..a04d88f 100644 --- a/docs/DEBUG_CHECKLIST.md +++ b/docs/DEBUG_CHECKLIST.md @@ -11,6 +11,34 @@ Both timers fire at the same time, so containers always exit via hard SIGKILL (c ### 3. Cursor advanced before agent succeeds `processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout. +### 4. Kubernetes image garbage collection deletes nanoclaw-agent image + +**Symptoms**: `Container exited with code 125: pull access denied for nanoclaw-agent` — the container image disappears overnight or after a few hours, even though you just built it. + +**Cause**: If your container runtime has Kubernetes enabled (Rancher Desktop enables it by default), the kubelet runs image garbage collection when disk usage exceeds 85%. NanoClaw containers are ephemeral (run and exit), so `nanoclaw-agent:latest` is never protected by a running container. The kubelet sees it as unused and deletes it — often overnight when no messages are being processed. Other images (docker-compose services) survive because they have long-running containers referencing them. + +**Fix**: Disable Kubernetes if you don't need it: +```bash +# Rancher Desktop +rdctl set --kubernetes-enabled=false + +# Then rebuild the container image +./container/build.sh +``` + +**Diagnosis**: Check the k3s log for image GC activity: +```bash +grep -i "nanoclaw" ~/Library/Logs/rancher-desktop/k3s.log +# Look for: "Removing image to free bytes" with the nanoclaw-agent image ID +``` + +Check NanoClaw logs for image status: +```bash +grep -E "image found|image NOT found|image missing" logs/nanoclaw.log +``` + +If you need Kubernetes enabled, set `CONTAINER_IMAGE` to an image stored in a registry that the kubelet won't GC, or raise the GC thresholds. + ## Quick Status Check ```bash From 877650541ae0eb2eb439b3ac3c5a8279f06ce157 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 14:10:01 +0000 Subject: [PATCH 107/124] chore: bump version to 1.2.37 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1dd2ea..a602361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.36", + "version": "1.2.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.36", + "version": "1.2.37", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 081d2b4..7216a9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.36", + "version": "1.2.37", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 6e602a1f5bb92e6277c3ebd98032ad7339ceb820 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 14:10:36 +0000 Subject: [PATCH 108/124] chore: bump version to 1.2.38 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a602361..d123488 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.37", + "version": "1.2.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.37", + "version": "1.2.38", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 7216a9d..a864db3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.37", + "version": "1.2.38", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 842ec5fd30b8745158d2aa49485b0103f3cf1606 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 14:15:52 +0000 Subject: [PATCH 109/124] chore: bump version to 1.2.39 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d123488..441e85d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.38", + "version": "1.2.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.38", + "version": "1.2.39", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a864db3..f40b899 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.38", + "version": "1.2.39", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 2faf1c6e19e65f471c82e93351df98554fa7ad20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 14:15:55 +0000 Subject: [PATCH 110/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.1k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 6e1646a..f30318f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.0k tokens, 21% of context window + + 42.1k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 42.0k + + 42.1k From c98205ca0d888c3ba25664520f8db1df302b6f21 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 27 Mar 2026 18:25:46 +0000 Subject: [PATCH 111/124] fix: prevent full message history from being sent to container agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When lastAgentTimestamp was missing (new group, corrupted state, or startup recovery), the empty-string fallback caused getMessagesSince to return up to 200 messages — the entire group history. This sent a massive prompt to the container agent instead of just recent messages. Fix: recover the cursor from the last bot reply timestamp in the DB (proof of what we already processed), and cap all prompt queries to a configurable MAX_MESSAGES_PER_PROMPT (default 10). Covers all three call sites: processGroupMessages, the piping path, and recoverPendingMessages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.ts | 4 +++ src/db.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.ts | 13 ++++++++ src/index.ts | 38 +++++++++++++++++++--- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index e1cbe11..12f04d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,10 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( ); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254'; +export const MAX_MESSAGES_PER_PROMPT = Math.max( + 1, + parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, +); export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max( diff --git a/src/db.test.ts b/src/db.test.ts index a40d376..ff4872a 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -6,6 +6,7 @@ import { deleteTask, getAllChats, getAllRegisteredGroups, + getLastBotMessageTimestamp, getMessagesSince, getNewMessages, getTaskById, @@ -14,6 +15,7 @@ import { storeMessage, updateTask, } from './db.js'; +import { formatMessages } from './router.js'; beforeEach(() => { _initTestDatabase(); @@ -208,6 +210,92 @@ describe('getMessagesSince', () => { expect(msgs).toHaveLength(3); }); + it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => { + // beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04) + // Add more old history before the bot reply + for (let i = 1; i <= 50; i++) { + store({ + id: `history-${i}`, + chat_jid: 'group@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: `old message ${i}`, + timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`, + }); + } + + // New message after the bot reply (m3 at 00:00:03) + store({ + id: 'new-1', + chat_jid: 'group@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'new message after bot reply', + timestamp: '2024-01-02T00:00:00.000Z', + }); + + // Recover cursor from the last bot message (m3 from beforeEach) + const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy'); + expect(recovered).toBe('2024-01-01T00:00:03.000Z'); + + // Using recovered cursor: only gets messages after the bot reply + const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10); + // m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2 + expect(msgs).toHaveLength(2); + expect(msgs[0].content).toBe('third'); + expect(msgs[1].content).toBe('new message after bot reply'); + }); + + it('caps messages to configured limit even with recovered cursor', () => { + // beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it. + for (let i = 1; i <= 30; i++) { + store({ + id: `pending-${i}`, + chat_jid: 'group@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: `pending message ${i}`, + timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`, + }); + } + + const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy'); + expect(recovered).toBe('2024-01-01T00:00:03.000Z'); + + // With limit=10, only the 10 most recent are returned + const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10); + expect(msgs).toHaveLength(10); + // Most recent 10: pending-21 through pending-30 + expect(msgs[0].content).toBe('pending message 21'); + expect(msgs[9].content).toBe('pending message 30'); + }); + + it('returns last N messages when no bot reply and no cursor exist', () => { + // Use a fresh group with no bot messages + storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z'); + for (let i = 1; i <= 20; i++) { + store({ + id: `fresh-${i}`, + chat_jid: 'fresh@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: `message ${i}`, + timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`, + }); + } + + const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy'); + expect(recovered).toBeUndefined(); + + // No cursor → sinceTimestamp = '' but limit caps the result + const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10); + expect(msgs).toHaveLength(10); + + const prompt = formatMessages(msgs, 'Asia/Jerusalem'); + const messageTagCount = (prompt.match(/ { // Simulate a message written before migration: has prefix but is_bot_message = 0 store({ diff --git a/src/db.ts b/src/db.ts index 718bc60..7fba354 100644 --- a/src/db.ts +++ b/src/db.ts @@ -375,6 +375,19 @@ export function getMessagesSince( .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; } +export function getLastBotMessageTimestamp( + chatJid: string, + botPrefix: string, +): string | undefined { + const row = db + .prepare( + `SELECT MAX(timestamp) as ts FROM messages + WHERE chat_jid = ? AND (is_bot_message = 1 OR content LIKE ?)`, + ) + .get(chatJid, `${botPrefix}:%`) as { ts: string | null } | undefined; + return row?.ts ?? undefined; +} + export function createTask( task: Omit, ): void { diff --git a/src/index.ts b/src/index.ts index 60fe910..80fc27f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { getTriggerPattern, GROUPS_DIR, IDLE_TIMEOUT, + MAX_MESSAGES_PER_PROMPT, ONECLI_URL, POLL_INTERVAL, TIMEZONE, @@ -33,6 +34,7 @@ import { getAllRegisteredGroups, getAllSessions, getAllTasks, + getLastBotMessageTimestamp, getMessagesSince, getNewMessages, getRouterState, @@ -112,6 +114,27 @@ function loadState(): void { ); } +/** + * Return the message cursor for a group, recovering from the last bot reply + * if lastAgentTimestamp is missing (new group, corrupted state, restart). + */ +function getOrRecoverCursor(chatJid: string): string { + const existing = lastAgentTimestamp[chatJid]; + if (existing) return existing; + + const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME); + if (botTs) { + logger.info( + { chatJid, recoveredFrom: botTs }, + 'Recovered message cursor from last bot reply', + ); + lastAgentTimestamp[chatJid] = botTs; + saveState(); + return botTs; + } + return ''; +} + function saveState(): void { setRouterState('last_timestamp', lastTimestamp); setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); @@ -205,11 +228,11 @@ async function processGroupMessages(chatJid: string): Promise { const isMainGroup = group.isMain === true; - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const missedMessages = getMessagesSince( chatJid, - sinceTimestamp, + getOrRecoverCursor(chatJid), ASSISTANT_NAME, + MAX_MESSAGES_PER_PROMPT, ); if (missedMessages.length === 0) return true; @@ -460,8 +483,9 @@ async function startMessageLoop(): Promise { // context that accumulated between triggers is included. const allPending = getMessagesSince( chatJid, - lastAgentTimestamp[chatJid] || '', + getOrRecoverCursor(chatJid), ASSISTANT_NAME, + MAX_MESSAGES_PER_PROMPT, ); const messagesToSend = allPending.length > 0 ? allPending : groupMessages; @@ -500,8 +524,12 @@ async function startMessageLoop(): Promise { */ function recoverPendingMessages(): void { for (const [chatJid, group] of Object.entries(registeredGroups)) { - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + const pending = getMessagesSince( + chatJid, + getOrRecoverCursor(chatJid), + ASSISTANT_NAME, + MAX_MESSAGES_PER_PROMPT, + ); if (pending.length > 0) { logger.info( { group: group.name, pendingCount: pending.length }, From e6e0c6fa9eff79b56b70d70f5c77a45d40b93432 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 18:42:36 +0000 Subject: [PATCH 112/124] chore: bump version to 1.2.40 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 441e85d..e2b8dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.39", + "version": "1.2.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.39", + "version": "1.2.40", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index f40b899..389b2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.39", + "version": "1.2.40", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From fa4ace423c31daa3df839a39c2fb9d876d9ba5d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 18:42:40 +0000 Subject: [PATCH 113/124] =?UTF-8?q?docs:=20update=20token=20count=20to=204?= =?UTF-8?q?2.4k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index f30318f..93aeb17 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 42.1k tokens, 21% of context window + + 42.4k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 42.1k + + 42.4k From 4f1b09fcb6d82e4c011dcf2ea68fea1f80f530f4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 22:36:45 +0300 Subject: [PATCH 114/124] fix: migrate x-integration host.ts from pino to built-in logger Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/x-integration/host.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts index a56269d..8971f64 100644 --- a/.claude/skills/x-integration/host.ts +++ b/.claude/skills/x-integration/host.ts @@ -8,12 +8,8 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; -import pino from 'pino'; -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } -}); +import { logger } from '../../../src/logger.js'; interface SkillResult { success: boolean; From acb0abaf8b4158566fbfef85d323b5c26038b85e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 27 Mar 2026 23:19:07 +0300 Subject: [PATCH 115/124] fix: broken tests and stale .env.example - Fix container-runner bug: stopContainer() returns void but was passed to exec() as a command string. Replace with direct call and try/catch. - Mock container-runtime in tests so they don't need Docker running. - Increase claw-skill test timeout to handle slower python startup. - Clear .env.example (telegram token was added by mistake). Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 1 - src/claw-skill.test.ts | 2 +- src/container-runner.test.ts | 8 ++++++++ src/container-runner.ts | 20 ++++++++++---------- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index b90e6c9..e69de29 100644 --- a/.env.example +++ b/.env.example @@ -1 +0,0 @@ -TELEGRAM_BOT_TOKEN= diff --git a/src/claw-skill.test.ts b/src/claw-skill.test.ts index 24260c9..2d86c8e 100644 --- a/src/claw-skill.test.ts +++ b/src/claw-skill.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from 'child_process'; import { describe, expect, it } from 'vitest'; describe('claw skill script', () => { - it('exits zero after successful structured output even if the runtime is terminated', () => { + it('exits zero after successful structured output even if the runtime is terminated', { timeout: 20000 }, () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-')); const binDir = path.join(tempDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index 64c3455..36fca0a 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -51,6 +51,14 @@ vi.mock('./mount-security.js', () => ({ validateAdditionalMounts: vi.fn(() => []), })); +// Mock container-runtime +vi.mock('./container-runtime.js', () => ({ + CONTAINER_RUNTIME_BIN: 'docker', + hostGatewayArgs: () => [], + readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`], + stopContainer: vi.fn(), +})); + // Mock OneCLI SDK vi.mock('@onecli-sh/sdk', () => ({ OneCLI: class { diff --git a/src/container-runner.ts b/src/container-runner.ts index facc68c..f6f86b1 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -2,7 +2,7 @@ * Container Runner for NanoClaw * Spawns agent execution in containers and handles IPC */ -import { ChildProcess, exec, spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -431,15 +431,15 @@ export async function runContainerAgent( { group: group.name, containerName }, 'Container timeout, stopping gracefully', ); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); - container.kill('SIGKILL'); - } - }); + try { + stopContainer(containerName); + } catch (err) { + logger.warn( + { group: group.name, containerName, err }, + 'Graceful stop failed, force killing', + ); + container.kill('SIGKILL'); + } }; let timeout = setTimeout(killOnTimeout, timeoutMs); From c3e9a892c2a7b0caaef97545b68f6cbc758bdeef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Mar 2026 20:19:23 +0000 Subject: [PATCH 116/124] chore: bump version to 1.2.41 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e2b8dcd..ffb6812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.40", + "version": "1.2.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.40", + "version": "1.2.41", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 389b2de..2034dd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.40", + "version": "1.2.41", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From fff37d590c2a916a5b0f0df0eaed05965ff2bb19 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 14:05:44 +0300 Subject: [PATCH 117/124] fix: setup skill routes credential system by container runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OneCLI is incompatible with Apple Container. Setup now picks the credential system after the container runtime: Docker → OneCLI, Apple Container → native credential proxy. Also marks Apple Container as experimental, pauses after claude setup-token, limits AskUserQuestion to multiple-choice, and removes telegram swarm upsell. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-telegram/SKILL.md | 8 --- .claude/skills/setup/SKILL.md | 93 ++++++++++++++++------------ 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index 10f25ab..609f394 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -202,14 +202,6 @@ launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # systemctl --user start nanoclaw ``` -## Agent Swarms (Teams) - -After completing the Telegram setup, use `AskUserQuestion`: - -AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions. - -If they say yes, invoke the `/add-telegram-swarm` skill. - ## Removal To remove Telegram integration: diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 54c3d2d..1996dfa 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -9,7 +9,7 @@ Run setup steps automatically. Only pause when user action is required (channel **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work. -**UX Note:** Use `AskUserQuestion` for all user-facing questions. +**UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "Docker or Apple Container?", "which channels?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply. ## 0. Git & Fork Setup @@ -50,7 +50,7 @@ Already configured. Continue. **Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`. -## 1. Bootstrap (Node.js + Dependencies + OneCLI) +## 1. Bootstrap (Node.js + Dependencies) Run `bash setup.sh` and parse the status block. @@ -62,34 +62,6 @@ Run `bash setup.sh` and parse the status block. - If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. - Record PLATFORM and IS_WSL for later steps. -After bootstrap succeeds, install OneCLI and its CLI tool: - -```bash -curl -fsSL onecli.sh/install | sh -curl -fsSL onecli.sh/cli/install | sh -``` - -Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it: - -```bash -export PATH="$HOME/.local/bin:$PATH" -# Persist for future sessions (append to shell profile if not already present) -grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc -``` - -Then re-verify with `onecli version`. - -Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): -```bash -onecli config set api-host http://127.0.0.1:10254 -``` - -Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): -```bash -grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env -``` - ## 2. Check Environment Run `npx tsx setup/index.ts --step environment` and parse the status block. @@ -112,7 +84,10 @@ Run `npx tsx setup/index.ts --step timezone` and parse the status block. Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1. - PLATFORM=linux → Docker (only option) -- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c. +- PLATFORM=macos + APPLE_CONTAINER=installed → AskUserQuestion with two options: + 1. **Docker (recommended)** — description: "Cross-platform, better credential management, well-tested." + 2. **Apple Container (experimental)** — description: "Native macOS runtime. Requires advanced setup." + If Apple Container, run `/convert-to-apple-container` now, then skip to 3c. - PLATFORM=macos + APPLE_CONTAINER=not_found → Docker ### 3a-docker. Install Docker @@ -147,9 +122,39 @@ Run `npx tsx setup/index.ts --step container -- --runtime ` and parse th **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. -## 4. Anthropic Credentials via OneCLI +## 4. Credential System -NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time. +The credential system depends on the container runtime chosen in step 3. + +### 4a. Docker → OneCLI + +Install OneCLI and its CLI tool: + +```bash +curl -fsSL onecli.sh/install | sh +curl -fsSL onecli.sh/cli/install | sh +``` + +Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it: + +```bash +export PATH="$HOME/.local/bin:$PATH" +# Persist for future sessions (append to shell profile if not already present) +grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +``` + +Then re-verify with `onecli version`. + +Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): +```bash +onecli config set api-host http://127.0.0.1:10254 +``` + +Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): +```bash +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env +``` Check if a secret already exists: ```bash @@ -163,16 +168,20 @@ AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an 1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." 2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." -### Subscription path +#### Subscription path -Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat. +Tell the user: -Once they have the token, they register it with OneCLI. AskUserQuestion with two options: +> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here. + +Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond. + +Once they confirm, they register it with OneCLI. AskUserQuestion with two options: 1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" -### API key path +#### API key path Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. @@ -181,7 +190,7 @@ Then AskUserQuestion with two options: 1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." 2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" -### After either path +#### After either path Ask them to let you know when done. @@ -189,6 +198,10 @@ Ask them to let you know when done. **After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. +### 4b. Apple Container → Native Credential Proxy + +Apple Container is not compatible with OneCLI. Invoke `/use-native-credential-proxy` to set up the built-in credential proxy instead. That skill handles credential collection, `.env` configuration, and verification. + ## 5. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? @@ -265,7 +278,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block. **If STATUS=failed, fix each:** - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) - SERVICE=not_found → re-run step 7 -- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret) +- CREDENTIALS=missing → re-run step 4 (Docker: check `onecli secrets list`; Apple Container: check `.env` for credentials) - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) - REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` @@ -274,7 +287,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## Troubleshooting -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), missing channel credentials (re-invoke channel skill). +**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl http://127.0.0.1:10254/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill). **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. From 90af26a6b1c1dec74f6d1cc139136ad1134f6b34 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 14:23:01 +0300 Subject: [PATCH 118/124] chore: remove claw skill test Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claw-skill.test.ts | 45 ------------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 src/claw-skill.test.ts diff --git a/src/claw-skill.test.ts b/src/claw-skill.test.ts deleted file mode 100644 index 2d86c8e..0000000 --- a/src/claw-skill.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { spawnSync } from 'child_process'; - -import { describe, expect, it } from 'vitest'; - -describe('claw skill script', () => { - it('exits zero after successful structured output even if the runtime is terminated', { timeout: 20000 }, () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-')); - const binDir = path.join(tempDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - - const runtimePath = path.join(binDir, 'container'); - fs.writeFileSync( - runtimePath, - `#!/bin/sh -cat >/dev/null -printf '%s\n' '---NANOCLAW_OUTPUT_START---' '{"status":"success","result":"4","newSessionId":"sess-1"}' '---NANOCLAW_OUTPUT_END---' -sleep 30 -`, - ); - fs.chmodSync(runtimePath, 0o755); - - const result = spawnSync( - 'python3', - ['.claude/skills/claw/scripts/claw', '-j', 'tg:123', 'What is 2+2?'], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { - ...process.env, - NANOCLAW_DIR: tempDir, - PATH: `${binDir}:${process.env.PATH || ''}`, - }, - timeout: 15000, - }, - ); - - expect(result.status).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stdout).toContain('4'); - expect(result.stderr).toContain('[session: sess-1]'); - }); -}); From 37aee02b464c6eabead9d1f41920e028597d3b62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Mar 2026 11:23:18 +0000 Subject: [PATCH 119/124] chore: bump version to 1.2.42 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ffb6812..be7152c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.41", + "version": "1.2.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.41", + "version": "1.2.42", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 2034dd3..a8dd43a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.41", + "version": "1.2.42", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 8bb8e036e462d89dcfa40127289cf0217cecf160 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 14:48:34 +0300 Subject: [PATCH 120/124] docs: add branch and fork maintenance guidelines Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/BRANCH-FORK-MAINTENANCE.md | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/BRANCH-FORK-MAINTENANCE.md diff --git a/docs/BRANCH-FORK-MAINTENANCE.md b/docs/BRANCH-FORK-MAINTENANCE.md new file mode 100644 index 0000000..a3772fd --- /dev/null +++ b/docs/BRANCH-FORK-MAINTENANCE.md @@ -0,0 +1,79 @@ +# Branch & Fork Maintenance Guidelines + +## Structure + +**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`. + +**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel. + +**`skill/*` and `feat/*` branches on upstream** — add features unrelated to channels (e.g. `skill/compact`, `skill/apple-container`). Users merge these into their clone to add capabilities. Channel-specific skill branches that duplicate the forks (e.g. `skill/whatsapp`, `skill/telegram`) are legacy. + +## How users add capabilities + +``` +user clones upstream main + ├── merges nanoclaw-whatsapp fork → adds WhatsApp + ├── merges skill/compact branch → adds /compact command + └── merges skill/apple-container → switches to Apple Container +``` + +## Merge directions + +``` +upstream main ──→ channel forks (forward merge to keep forks caught up) +upstream main ──→ skill branches (forward merge to keep branches caught up) +``` + +Forks and skill branches carry applied code changes. Users merge them into their own clones/forks to add capabilities. They are never merged back into upstream `main`. + +## Forward merge procedure + +```bash +# In your local nanoclaw checkout +git checkout main && git pull + +# For a fork: +git fetch nanoclaw-whatsapp +git checkout -B whatsapp-merge nanoclaw-whatsapp/main +git merge main +# Resolve conflicts (see below) +git push nanoclaw-whatsapp HEAD:main +git checkout main && git branch -D whatsapp-merge + +# For a skill branch: +git checkout -B skill/compact origin/skill/compact +git merge main +# Resolve conflicts (see below) +git push origin skill/compact +git checkout main && git branch -D skill/compact +``` + +## Conflict resolution + +The same files conflict every time: + +| File | Resolution | +|------|------------| +| `package.json` | Take main's version + keep fork/branch-specific deps | +| `package-lock.json` | `git checkout main -- package-lock.json && npm install` | +| `.env.example` | Combine: main's entries + fork/branch-specific entries | +| `repo-tokens/badge.svg` | Take main's version (auto-generated) | + +Source code merges cleanly because forks/branches primarily add files rather than modifying shared code. + +## When to merge forward + +After any main change that touches shared files (`package.json`, `src/index.ts`, `CLAUDE.md`, etc.). Small frequent merges = trivial conflicts. Large infrequent merges = painful. + +## Fork setup + +When creating a new channel fork: + +1. Fork `nanoclaw` to `nanoclaw-{channel}` +2. Remove upstream-only workflows: `bump-version.yml`, `update-tokens.yml` +3. Add channel code, deps, env vars +4. Forward-merge main immediately to establish a clean baseline + +## Dependencies + +Forks and branches add their own deps on top of upstream's. When upstream adds or removes a dependency, verify that forks/branches still build after the next forward merge — transitive dependency changes can break downstream code. From 8c4ab36ef2d534f75190a4f852b50a12889c205e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 15:05:57 +0300 Subject: [PATCH 121/124] docs: update fork maintenance guide with merge learnings Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/BRANCH-FORK-MAINTENANCE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/BRANCH-FORK-MAINTENANCE.md b/docs/BRANCH-FORK-MAINTENANCE.md index a3772fd..9272818 100644 --- a/docs/BRANCH-FORK-MAINTENANCE.md +++ b/docs/BRANCH-FORK-MAINTENANCE.md @@ -37,6 +37,8 @@ git fetch nanoclaw-whatsapp git checkout -B whatsapp-merge nanoclaw-whatsapp/main git merge main # Resolve conflicts (see below) +# Remove upstream-only workflows if they were re-added by the merge: +git rm .github/workflows/bump-version.yml .github/workflows/update-tokens.yml 2>/dev/null git push nanoclaw-whatsapp HEAD:main git checkout main && git branch -D whatsapp-merge @@ -59,7 +61,7 @@ The same files conflict every time: | `.env.example` | Combine: main's entries + fork/branch-specific entries | | `repo-tokens/badge.svg` | Take main's version (auto-generated) | -Source code merges cleanly because forks/branches primarily add files rather than modifying shared code. +Source code changes (e.g. `src/types.ts`, `src/index.ts`) usually auto-merge cleanly, but can conflict if both sides modify the same lines. Build and test after every forward merge. ## When to merge forward From 3ab833b4eb7a1a210a5c22c3c9118898dd1a6ecd Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 15:14:07 +0300 Subject: [PATCH 122/124] docs: note that workflow removal recurs on every forward merge Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/BRANCH-FORK-MAINTENANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BRANCH-FORK-MAINTENANCE.md b/docs/BRANCH-FORK-MAINTENANCE.md index 9272818..59a3988 100644 --- a/docs/BRANCH-FORK-MAINTENANCE.md +++ b/docs/BRANCH-FORK-MAINTENANCE.md @@ -37,7 +37,7 @@ git fetch nanoclaw-whatsapp git checkout -B whatsapp-merge nanoclaw-whatsapp/main git merge main # Resolve conflicts (see below) -# Remove upstream-only workflows if they were re-added by the merge: +# Remove upstream-only workflows (re-added by every merge since main has them): git rm .github/workflows/bump-version.yml .github/workflows/update-tokens.yml 2>/dev/null git push nanoclaw-whatsapp HEAD:main git checkout main && git branch -D whatsapp-merge From a3fb3beb6ac28757e0a3dbe3b64862cd4839f8ce Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 15:59:57 +0300 Subject: [PATCH 123/124] docs: warn about silently wrong auto-merges in maintenance guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/BRANCH-FORK-MAINTENANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BRANCH-FORK-MAINTENANCE.md b/docs/BRANCH-FORK-MAINTENANCE.md index 59a3988..4891f38 100644 --- a/docs/BRANCH-FORK-MAINTENANCE.md +++ b/docs/BRANCH-FORK-MAINTENANCE.md @@ -61,7 +61,7 @@ The same files conflict every time: | `.env.example` | Combine: main's entries + fork/branch-specific entries | | `repo-tokens/badge.svg` | Take main's version (auto-generated) | -Source code changes (e.g. `src/types.ts`, `src/index.ts`) usually auto-merge cleanly, but can conflict if both sides modify the same lines. Build and test after every forward merge. +Source code changes (e.g. `src/types.ts`, `src/index.ts`) usually auto-merge cleanly, but can conflict if both sides modify the same lines. **Always build and test after every forward merge** — auto-merged code can be silently wrong (e.g. referencing a renamed function or using a removed parameter) even when git reports no conflicts. ## When to merge forward From 29839464bff39b7672c0a7504d9bb7649a79c5f1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 28 Mar 2026 16:25:56 +0300 Subject: [PATCH 124/124] fix: setup skill skips /use-native-credential-proxy for apple container The apple-container branch already includes the credential proxy code. Applying /use-native-credential-proxy on top would conflict. Setup now inlines the credential collection steps instead of delegating. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/SKILL.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 1996dfa..fb13c05 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -200,7 +200,28 @@ Ask them to let you know when done. ### 4b. Apple Container → Native Credential Proxy -Apple Container is not compatible with OneCLI. Invoke `/use-native-credential-proxy` to set up the built-in credential proxy instead. That skill handles credential collection, `.env` configuration, and verification. +Apple Container is not compatible with OneCLI. The credential proxy code is already included in the apple-container branch — do NOT invoke `/use-native-credential-proxy` (it would conflict with already-applied code). + +Instead, just configure the credentials in `.env`: + +AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? + +1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. Run `claude setup-token` in another terminal to get your token." +2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." + +For subscription: tell the user to run `claude setup-token` in another terminal. Stop and wait for the user to confirm they have completed this step successfully before proceeding. + +Once confirmed, add the token to `.env`: +```bash +echo 'CLAUDE_CODE_OAUTH_TOKEN=' >> .env +``` + +For API key: add to `.env`: +```bash +echo 'ANTHROPIC_API_KEY=' >> .env +``` + +Verify the proxy starts: `npm run dev` should show "Credential proxy listening" in the logs. ## 5. Set Up Channels