mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96180436e9 | |||
| 934f063aff | |||
| 32a487b96b | |||
| 751a9ed2d1 | |||
| 22d7856ce0 | |||
| ca9333d48d | |||
| 6c289c3a80 | |||
| b8cf30830b | |||
| 5702760206 | |||
| 653390d9aa | |||
| 3381509e69 | |||
| 19ce90c663 | |||
| 0918f78a0c | |||
| 4fd75860cd | |||
| 5adc9497b3 | |||
| 1d5c38d15a | |||
| 75c2e1868f | |||
| f77f9ce2c4 | |||
| 27f9f0ca32 | |||
| 0c67fbf456 | |||
| 15e356a572 | |||
| 33b5627f42 | |||
| f69979fb9e | |||
| 54bf4543f2 | |||
| 36943fbcfd | |||
| 1488c5b251 | |||
| 22ab96ccac | |||
| 391b729623 | |||
| 3703c9decb | |||
| c5cb97b761 | |||
| 761d3a1b30 | |||
| cbb4da19c7 | |||
| b752e5cd34 | |||
| a74be06956 | |||
| d4a6b4a3b5 | |||
| 67020f9fbf | |||
| 9019e4e3b8 | |||
| 8a02170b21 | |||
| db3440f662 | |||
| b2a5a58f8a | |||
| 426ae0285e | |||
| 7ef1c4f5e0 | |||
| f60bb3c3d5 | |||
| 3608f05233 | |||
| 8f28cde41d | |||
| 032ba77a7f | |||
| e9db4d461d | |||
| 584114118d | |||
| bf11109825 | |||
| 6f93b20cd1 | |||
| f23a54aea0 | |||
| 6e0653f537 | |||
| ee599b9f0c | |||
| 7b337a7a07 | |||
| 3e2895987b | |||
| 22f5d55855 | |||
| 51f50bbe85 | |||
| 4c7bc80299 | |||
| 87e89147c9 | |||
| 7b0d79a6f3 | |||
| 468c6170a0 | |||
| 4c8b9cda93 | |||
| 78bfb8df85 | |||
| a86641f69e | |||
| 59c09effcb | |||
| 001ee6ec48 | |||
| 9d97f79476 | |||
| d675859c24 | |||
| 38009be263 | |||
| 3098f28b74 | |||
| 474346e214 | |||
| 29839464bf | |||
| a3fb3beb6a | |||
| 54a8648c95 | |||
| 69348510e9 | |||
| 17a72938be | |||
| 4511644d0d | |||
| 86063e0ea0 | |||
| d1ce15a4de | |||
| 5b24dd4d2e | |||
| 0d8f7f8668 | |||
| fff32f3028 | |||
| 1bb065e655 | |||
| ea7561a978 | |||
| cfc4b6c28e | |||
| dad98b0a8f | |||
| 3e41e54e10 | |||
| 4d9f0288ee | |||
| 972edd14f6 | |||
| fd59ff0ec9 | |||
| e2e32219c9 | |||
| c601aaa947 | |||
| d43d53244f | |||
| e8326bae62 | |||
| d71ffaf7ef | |||
| 5b5ee91aa7 | |||
| 2007471f4f | |||
| 9e90c0712e | |||
| 2317302745 | |||
| b247357e0d | |||
| 4dd27adb84 | |||
| cc4f03a203 | |||
| 4bc232e513 | |||
| c9d1569702 | |||
| 5b20e2908a | |||
| 089fcea474 | |||
| bd64fd667d | |||
| f0ac7fbb6d | |||
| 207addfa19 | |||
| 4afb5bd9f1 | |||
| dfcdfcac11 | |||
| d33e514d04 | |||
| 4cb13b2b60 |
+10
-1
@@ -1 +1,10 @@
|
||||
{}
|
||||
{
|
||||
"sandbox": {
|
||||
"network": {
|
||||
"allowedDomains": [
|
||||
"npm.registry.com",
|
||||
"us.i.posthog.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,27 @@ All tests must pass (including the new Gmail tests) and build must be clean befo
|
||||
ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
|
||||
```
|
||||
|
||||
If `credentials.json` already exists, skip to "Build and restart" below.
|
||||
If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below.
|
||||
|
||||
### GCP Project Setup
|
||||
|
||||
Tell the user:
|
||||
Check if OneCLI is configured:
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual"
|
||||
```
|
||||
|
||||
**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done.
|
||||
|
||||
Once the user confirms, run:
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values.
|
||||
|
||||
**If manual:** Tell the user:
|
||||
|
||||
> I need you to set up Google Cloud OAuth credentials:
|
||||
>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: add-karpathy-llm-wiki
|
||||
description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki", "karpathy wiki".
|
||||
---
|
||||
|
||||
# Add Karpathy LLM Wiki
|
||||
|
||||
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
|
||||
|
||||
## Step 1: Read the pattern
|
||||
|
||||
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
|
||||
|
||||
## Step 2: Choose a group
|
||||
|
||||
AskUserQuestion: "Which group should have the wiki?"
|
||||
|
||||
1. **Main group** — add to your existing main chat
|
||||
2. **Dedicated group** — create a new group just for the wiki
|
||||
3. **Other** — pick an existing group
|
||||
|
||||
If dedicated: ask which channel and chat, then register with `npx tsx setup/index.ts --step register`.
|
||||
|
||||
## Step 3: Design collaboratively
|
||||
|
||||
Discuss with the user based on the pattern:
|
||||
- What's the wiki's domain or topic?
|
||||
- What kinds of sources will they add? (URLs, PDFs, images, voice notes, books, transcripts)
|
||||
- Do they want the full three-layer architecture or a lighter version?
|
||||
- Any specific conventions they care about? (The pattern intentionally leaves this open.)
|
||||
|
||||
Based on this discussion, create three things:
|
||||
|
||||
### 3a. Directory structure
|
||||
|
||||
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
|
||||
|
||||
### 3b. Container skill
|
||||
|
||||
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
|
||||
|
||||
### 3c. Group CLAUDE.md
|
||||
|
||||
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
|
||||
|
||||
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
|
||||
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
|
||||
- Point to the container skill for detailed workflow
|
||||
- **Ingest discipline:** Be very explicit that when the user provides multiple files or points at a folder with many files, the agent MUST process them one at a time. For each file: read it, discuss takeaways, create/update all wiki pages (summary, entities, concepts, cross-references, index, log), and completely finish with that file before moving to the next. Never batch-read all files and then process them together — this produces shallow, generic pages instead of the deep integration the pattern requires.
|
||||
|
||||
## Step 4: Source handling capabilities
|
||||
|
||||
Based on the source types the user plans to ingest (discussed in Step 3), check whether the agent can already handle those formats — some are supported natively, others need a skill (e.g. `/add-image-vision`, `/add-pdf-reader`, `/add-voice-transcription`). If a needed capability isn't installed, check if there's an available skill for it and help the user get it set up.
|
||||
|
||||
### URL handling note
|
||||
|
||||
claude has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion of a URL where the full text matters, the container skill and CLAUDE.md should instruct claude to use bash commands to download full files instead. For example:
|
||||
|
||||
```bash
|
||||
curl -sLo sources/filename.pdf "<url>"
|
||||
```
|
||||
|
||||
If the document is a webpage, then claude can use fetch or `agent-browser` to open the page and extract full text if available. The container skill and CLAUDE.md should note this so claude gets full content for sources rather than summaries.
|
||||
|
||||
|
||||
## Step 5: Optional lint schedule
|
||||
|
||||
AskUserQuestion: "Want periodic wiki health checks?"
|
||||
|
||||
1. **Weekly**
|
||||
2. **Monthly**
|
||||
3. **Skip** — lint manually
|
||||
|
||||
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
|
||||
|
||||
```bash
|
||||
npx tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
||||
const nextRun = interval.next().toISOString();
|
||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||
'wiki-lint',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
||||
'cron',
|
||||
'<cron-expr>',
|
||||
'group',
|
||||
nextRun,
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
||||
|
||||
## Step 6: Build and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
Tell the user to test by sending a source to the wiki group.
|
||||
@@ -0,0 +1,75 @@
|
||||
# LLM Wiki
|
||||
|
||||
> Source: [karpathy/llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
||||
|
||||
A pattern for building personal knowledge bases using LLMs.
|
||||
|
||||
This is an idea file, designed to be copied to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, etc.). Its goal is to communicate the high-level idea, with your agent building out specifics through collaboration with you.
|
||||
|
||||
## The Core Idea
|
||||
|
||||
Most interactions with LLMs and documents follow RAG patterns: upload files, retrieve relevant chunks at query time, generate answers. The knowledge is re-derived on each question with no accumulation.
|
||||
|
||||
The concept here differs fundamentally. Rather than just retrieving from raw documents, the LLM incrementally builds and maintains a persistent wiki — a structured, interlinked markdown collection sitting between you and raw sources. When adding new material, the LLM reads it, extracts key information, and integrates it into existing wiki pages—updating entities, revising summaries, flagging contradictions, strengthening synthesis. Knowledge compiles once and stays current rather than re-deriving on every query.
|
||||
|
||||
The wiki becomes a persistent, compounding artifact. Cross-references already exist. Contradictions are flagged. Synthesis reflects everything read. The wiki enriches with every source added and question asked.
|
||||
|
||||
You source material and ask questions; the LLM maintains everything—summarizing, cross-referencing, filing, and organizing. The LLM acts as programmer; Obsidian serves as IDE; the wiki functions as codebase.
|
||||
|
||||
**Applications include:**
|
||||
- Personal: tracking goals, health, self-improvement
|
||||
- Research: deep dives over weeks/months
|
||||
- Reading: building companion wikis while progressing through books
|
||||
- Business/teams: internal wikis fed by Slack, transcripts, documents
|
||||
- Analysis: competitive research, due diligence, trip planning, hobby deep-dives
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers comprise the system:
|
||||
|
||||
**Raw sources** — immutable curated documents (articles, papers, images, data). The LLM reads but never modifies these.
|
||||
|
||||
**The wiki** — LLM-generated markdown directories containing summaries, entity pages, concept pages, comparisons, syntheses. The LLM owns this entirely, creating and updating pages while maintaining cross-references and consistency.
|
||||
|
||||
**The schema** — configuration document (e.g., CLAUDE.md) explaining wiki structure, conventions, and workflows for ingestion, querying, and maintenance. This key file transforms the LLM into disciplined wiki maintainer rather than generic chatbot.
|
||||
|
||||
## Operations
|
||||
|
||||
**Ingest:** Drop new sources into the raw collection; the LLM processes them. The agent reads sources, discusses takeaways, writes summaries, updates indexes, refreshes entity and concept pages, logs entries. Single sources might touch 10-15 wiki pages. Prefer ingesting individually while staying involved, though batch ingestion with less oversight is possible.
|
||||
|
||||
**Query:** Ask questions against the wiki. The LLM searches relevant pages, synthesizes answers with citations. Answers take various forms—markdown pages, comparison tables, slide decks, charts, canvas. Good answers can be filed back into the wiki as new pages—explorations compound in the knowledge base rather than disappearing into chat history.
|
||||
|
||||
**Lint:** Periodically health-check the wiki. Look for contradictions, stale claims superseded by newer sources, orphan pages lacking inbound links, important concepts lacking dedicated pages, missing cross-references, data gaps. The LLM suggests investigations and sources to pursue, keeping the wiki healthy as it grows.
|
||||
|
||||
## Indexing and Logging
|
||||
|
||||
Two special files help navigate the growing wiki:
|
||||
|
||||
**index.md** — content-oriented catalog of everything (each page with link, one-line summary, optional metadata like dates or source counts), organized by category. The LLM updates it on every ingest. When answering queries, read the index first to locate relevant pages before drilling deeper. This approach works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) while avoiding embedding-based RAG infrastructure needs.
|
||||
|
||||
**log.md** — append-only chronological record of what happened and when (ingests, queries, lint passes). Each entry beginning with consistent prefix (e.g., `## [2026-04-02] ingest | Article Title`) becomes parseable with simple tools—`grep "^## \[" log.md | tail -5` yields last 5 entries. The log shows wiki evolution timeline and helps the LLM understand recent activity.
|
||||
|
||||
## Optional: CLI Tools
|
||||
|
||||
At scale, small tools help the LLM operate more efficiently. Search engine over wiki pages is most obvious—at small scale the index suffices, but as the wiki grows, proper search becomes necessary. qmd (https://github.com/tobi/qmd) offers local search with hybrid BM25/vector search and LLM re-ranking, entirely on-device. It includes both CLI (so LLMs can shell out) and MCP server (native tool integration). Build simpler custom search scripts as needs arise.
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
- **Obsidian Web Clipper** converts web articles to markdown for quick source collection
|
||||
- **Download images locally:** Set attachment folder in Obsidian Settings, bind download hotkey. All images store locally; LLM views and references directly instead of relying on potentially broken URLs
|
||||
- **Obsidian's graph view** visualizes wiki connectivity—what connects to what, hub pages, orphans
|
||||
- **Marp** provides markdown-based slide deck format with Obsidian plugin integration
|
||||
- **Dataview** plugin queries page frontmatter, generating dynamic tables/lists when LLM adds YAML frontmatter
|
||||
- The wiki is simply a git-backed markdown directory—version history, branching, collaboration included
|
||||
|
||||
## Why This Works
|
||||
|
||||
Knowledge base maintenance's tedious part is bookkeeping, not reading/thinking: updating cross-references, keeping summaries current, noting data contradictions, maintaining consistency across pages. Humans abandon wikis as maintenance burden outpaces value. LLMs don't bore, don't forget updates, can touch 15 files in one pass. Wiki maintenance becomes nearly free.
|
||||
|
||||
Humans curate sources, direct analysis, ask good questions, think about meaning. LLMs handle everything else.
|
||||
|
||||
This relates in spirit to Vannevar Bush's 1945 Memex—personal curated knowledge stores with associative document trails. Bush's vision resembled this more than what the web became: private, actively curated, with connections between documents as valuable as documents themselves. Bush couldn't solve maintenance; LLMs handle that.
|
||||
|
||||
## Note
|
||||
|
||||
This document intentionally remains abstract, describing the idea rather than specific implementation. Directory structure, schema conventions, page formats, tooling—all depend on domain, preferences, and LLM choice. Everything is optional and modular. Pick what's useful; ignore what isn't. Your sources might be text-only (no image handling needed). Your wiki might stay small enough that index files suffice (no search engine required). You might want different output formats entirely. Share this with your LLM agent and work collaboratively to instantiate a version fitting your needs. This document's sole purpose is communicating the pattern; your LLM figures out the rest.
|
||||
@@ -45,7 +45,7 @@ Apple Container requires macOS. It does not work on Linux.
|
||||
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
|
||||
```
|
||||
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3.
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
@@ -86,7 +86,44 @@ npm run build
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Verify
|
||||
## Phase 3: Credential proxy network binding
|
||||
|
||||
Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials.
|
||||
|
||||
Use AskUserQuestion to ask the user:
|
||||
|
||||
**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"**
|
||||
|
||||
Options:
|
||||
1. **Yes, private/home network** — description: "No firewall rule needed."
|
||||
2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001."
|
||||
|
||||
For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`:
|
||||
|
||||
```bash
|
||||
grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env
|
||||
```
|
||||
|
||||
If they chose the public network option, set up and persist the firewall rule:
|
||||
|
||||
```bash
|
||||
echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
```bash
|
||||
grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy
|
||||
block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null
|
||||
```
|
||||
|
||||
Verify the rule is working:
|
||||
|
||||
```bash
|
||||
curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active"
|
||||
```
|
||||
|
||||
If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Ensure Apple Container runtime is running
|
||||
|
||||
|
||||
@@ -17,13 +17,7 @@ This skill installs OneCLI, configures the Agent Vault gateway, and migrates any
|
||||
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:
|
||||
If the command succeeds, OneCLI is installed, check for an Anthropic secret:
|
||||
|
||||
```bash
|
||||
onecli secrets list
|
||||
@@ -81,16 +75,16 @@ Re-verify with `onecli version`.
|
||||
|
||||
### Configure the CLI
|
||||
|
||||
Point the CLI at the local OneCLI instance:
|
||||
Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above:
|
||||
|
||||
```bash
|
||||
onecli config set api-host http://127.0.0.1:10254
|
||||
onecli config set api-host ${ONECLI_URL}
|
||||
```
|
||||
|
||||
### Set ONECLI_URL in .env
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env
|
||||
```
|
||||
|
||||
### Wait for gateway readiness
|
||||
@@ -99,7 +93,7 @@ The gateway may take a moment to start after installation. Poll for up to 15 sec
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf http://127.0.0.1:10254/health && break
|
||||
curl -sf ${ONECLI_URL}/health && break
|
||||
sleep 1
|
||||
done
|
||||
```
|
||||
@@ -214,7 +208,7 @@ Tell the user to run `claude setup-token` in another terminal and copy the token
|
||||
|
||||
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."
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} 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
|
||||
@@ -223,7 +217,7 @@ Tell the user to get an API key from https://console.anthropic.com/settings/keys
|
||||
|
||||
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."
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} 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
|
||||
@@ -262,12 +256,12 @@ If the service is running and a channel is configured, tell the user to send a t
|
||||
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 manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||
- 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.
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/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`).
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Migrating OpenClaw Cron Jobs to NanoClaw Scheduled Tasks
|
||||
|
||||
This file is referenced by SKILL.md Phase 5 when cron jobs are detected.
|
||||
|
||||
**Before inserting tasks:** Read `src/db.ts` and search for `scheduled_tasks` to verify the current table schema. The schema below is a reference — if columns have been added, removed, or renamed, use the current schema from the source code.
|
||||
|
||||
Also verify the `createTask` function signature in `src/db.ts` — it may be simpler to call it via a script than raw SQL.
|
||||
|
||||
## OpenClaw Cron Job Format
|
||||
|
||||
Source: `<STATE_DIR>/cron/jobs.json` (from `src/cron/types.ts`). If the file format doesn't match what's described below, read the actual file and adapt — OpenClaw may have changed the schema.
|
||||
|
||||
The jobs file is `{ version: 1, jobs: CronJob[] }`. Each job has:
|
||||
- `id`, `name`, `description`, `enabled`, `deleteAfterRun`
|
||||
- `schedule`: `{ kind: "cron", expr: string, tz?: string }` | `{ kind: "every", everyMs: number }` | `{ kind: "at", at: string }`
|
||||
- `payload`: `{ kind: "agentTurn", message: string, model?, thinking?, timeoutSeconds? }` | `{ kind: "systemEvent", text: string }`
|
||||
- `sessionTarget`: `"main"` | `"isolated"` | `"current"` | `"session:<id>"`
|
||||
- `wakeMode`: `"next-heartbeat"` | `"now"`
|
||||
- `delivery`: `{ mode: "none" | "announce" | "webhook", channel?, to?, threadId?, bestEffort? }`
|
||||
- `failureAlert`: `{ after?: number, channel?, to?, cooldownMs? }` | `false`
|
||||
- `state`: runtime state (nextRunAtMs, lastRunStatus, consecutiveErrors, etc.)
|
||||
|
||||
## NanoClaw `scheduled_tasks` Table
|
||||
|
||||
Source: `src/db.ts`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | TEXT PK | Unique task ID |
|
||||
| `group_folder` | TEXT | Target group directory (e.g. `"main"`) |
|
||||
| `chat_jid` | TEXT | Target chat JID |
|
||||
| `prompt` | TEXT | Task instructions |
|
||||
| `script` | TEXT | Optional bash pre-check script |
|
||||
| `schedule_type` | TEXT | `"cron"`, `"interval"`, or `"once"` |
|
||||
| `schedule_value` | TEXT | Cron expr, ms interval, or ISO timestamp |
|
||||
| `context_mode` | TEXT | `"group"` or `"isolated"` (default) |
|
||||
| `next_run` | TEXT | ISO timestamp — must be computed at insert time |
|
||||
| `last_run` | TEXT | null initially |
|
||||
| `last_result` | TEXT | null initially |
|
||||
| `status` | TEXT | `"active"`, `"paused"`, or `"completed"` |
|
||||
| `created_at` | TEXT | ISO timestamp |
|
||||
|
||||
## Field Mapping
|
||||
|
||||
- `schedule.kind:"cron"` + `schedule.expr` → `schedule_type:"cron"`, `schedule_value:<expr>`
|
||||
- `schedule.kind:"every"` + `schedule.everyMs` → `schedule_type:"interval"`, `schedule_value:<ms as string>`
|
||||
- `schedule.kind:"at"` + `schedule.at` → `schedule_type:"once"`, `schedule_value:<ISO timestamp>`
|
||||
- `payload.message` or `payload.text` → `prompt`
|
||||
- `sessionTarget:"isolated"` → `context_mode:"isolated"`, `sessionTarget:"main"` or `"current"` → `context_mode:"group"`
|
||||
|
||||
## What Doesn't Map
|
||||
|
||||
- `delivery.mode:"webhook"` — NanoClaw has no webhook delivery. Discuss with the user: this could be implemented as a task `script` that runs `curl` to hit the webhook endpoint.
|
||||
- `failureAlert` — NanoClaw has no failure alert system. Note this to the user.
|
||||
- `wakeMode` — NanoClaw tasks always wake the agent immediately.
|
||||
- `payload.model`, `payload.thinking`, `payload.timeoutSeconds` — NanoClaw doesn't support per-task model/thinking config. These are handled by the SDK.
|
||||
- `deleteAfterRun` — NanoClaw `"once"` tasks are marked `"completed"` after running, not deleted.
|
||||
|
||||
## For Each Enabled Job
|
||||
|
||||
1. Show what it does: name, schedule, prompt, delivery mode
|
||||
2. Explain any differences (no retry config, no webhook delivery, no failure alerts)
|
||||
3. If `delivery.mode:"webhook"`: discuss with the user — a task `script` with `curl` often suffices
|
||||
4. Ask if they want to keep this task
|
||||
|
||||
## Inserting Tasks
|
||||
|
||||
Insert directly into the SQLite database. This requires groups to be registered first (Phase 1). Use the registered group's `folder` and `chat_jid`:
|
||||
|
||||
```bash
|
||||
npx tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
// Compute next_run for cron tasks:
|
||||
// const interval = CronExpressionParser.parse('<expr>', { tz: process.env.TZ || 'UTC' });
|
||||
// const nextRun = interval.next().toISOString();
|
||||
db.prepare(\`INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\`).run(
|
||||
'migrated-<original-id>',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'<mapped prompt>',
|
||||
null,
|
||||
'<mapped schedule_type>',
|
||||
'<mapped schedule_value>',
|
||||
'<mapped context_mode>',
|
||||
'<computed next_run ISO>',
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
**Computing `next_run`:**
|
||||
- `cron` tasks: use `CronExpressionParser.parse(expr, { tz }).next().toISOString()`
|
||||
- `interval` tasks: `new Date(Date.now() + ms).toISOString()`
|
||||
- `once` tasks: `next_run` equals `schedule_value`
|
||||
|
||||
If groups haven't been registered yet (database doesn't exist), save the task details to `groups/main/openclaw-migration-tasks.md` with the exact SQL payloads, and tell the user: "These tasks will be created after `/setup` registers your groups."
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
name: migrate-from-openclaw
|
||||
description: Migrate from OpenClaw to NanoClaw. Detects existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw".
|
||||
---
|
||||
|
||||
# Migrate from OpenClaw
|
||||
|
||||
Guide the user through migrating their OpenClaw installation to NanoClaw. This is a conversation, not a batch job. Read OpenClaw state, discuss it with the user, make judgment calls together about what to bring over and how.
|
||||
|
||||
**Principle:** Never silently copy data. Read it, explain it, discuss where it belongs in NanoClaw's architecture, show proposed changes before applying. Credentials must be masked when displayed (first 4 + `...` + last 4 characters). Make judgment calls about what's core vs. reference material.
|
||||
|
||||
**UX:** Use `AskUserQuestion` for multiple-choice only. Use plain text for free-form input. Don't dump raw data — summarize and explain conversationally.
|
||||
|
||||
## Migration State File
|
||||
|
||||
Create `migration-state.md` in the project root at the start of Phase 0. Update it after each phase completes. This file is the single source of truth for the migration — if context is compacted or lost, re-read it to recover all decisions and progress.
|
||||
|
||||
Before starting any phase, re-read `migration-state.md` to ensure you have current state.
|
||||
|
||||
Sections to maintain (add data as each phase completes):
|
||||
|
||||
- **Progress** — checkbox list of phases (Phase 0–7)
|
||||
- **Discovery** — STATE_DIR, IDENTITY_NAME, channels, groups (with JID mappings), workspace files, cron job count, MCP servers
|
||||
- **Decisions** — assistant_name, group_model (shared/separate/main-only), main_group (folder + jid)
|
||||
- **Registered Groups** — table: folder, jid, channel, is_main
|
||||
- **Settings Migrated** — timezone, anthropic_credential (masked), sender_allowlist (created/skipped)
|
||||
- **Identity & Memory** — paths of files created, which CLAUDE.md was edited
|
||||
- **Channel Credentials** — table: channel, status, env_var
|
||||
- **Scheduled Tasks** — table: original_id, name, migrated/deferred
|
||||
- **Deferred / Not Applicable** — unsupported channels, discussed customizations, OpenClaw-only features
|
||||
|
||||
Keep it factual and terse — this is for machine recovery after compaction, not human reading. Delete the file at the end of Phase 7 (or offer to keep it as a record).
|
||||
|
||||
## Phase 0: Discovery
|
||||
|
||||
Run the discovery script to find and summarize the OpenClaw installation:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts
|
||||
```
|
||||
|
||||
If the user specifies a custom path, pass it: `--state-dir <path>`
|
||||
|
||||
Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS, WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS, MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS.
|
||||
|
||||
**Sanity-check the output:** The discovery script detects known structures but can silently miss data if OpenClaw's format has changed. Check `CONFIG_TOP_KEYS` and `CONFIG_CHANNEL_KEYS` — if you see keys the script didn't report on (e.g. a channel name not in CHANNELS, or a top-level section like `integrations` or `plugins`), read that section of the config directly with the Read tool. Also check `STATE_DIR_CONTENTS` for directories the script doesn't scan (e.g. unexpected folders alongside `workspace/`, `agents/`, `cron/`).
|
||||
|
||||
**If STATUS=not_found:** Tell the user no OpenClaw installation was detected at the standard locations (`~/.openclaw`, `~/.clawdbot`). Ask if they have a custom path. If not, exit.
|
||||
|
||||
**If STATUS=found:** Present a human-readable summary:
|
||||
|
||||
- "I found your OpenClaw installation at `<STATE_DIR>`."
|
||||
- Identity: name from IDENTITY.md (if found)
|
||||
- Workspace files: which of SOUL.md, USER.md, MEMORY.md, IDENTITY.md exist
|
||||
- Channels: list each, note which NanoClaw supports (whatsapp, telegram, slack, discord) and which it doesn't
|
||||
- Daily memory files: count (if any)
|
||||
- Skills: count and names (from workspace, shared, personal, project locations)
|
||||
- Cron jobs: count and names
|
||||
- MCP servers: count and names
|
||||
- Agents: count (relevant for Phase 1 groups discussion)
|
||||
|
||||
Then explain the key architectural differences. Don't dump a table — paraphrase conversationally:
|
||||
|
||||
- **Container isolation:** NanoClaw runs each agent in an isolated Linux container (Docker or Apple Container). OpenClaw runs everything in one process. This means stronger isolation but also means each group is its own sandbox.
|
||||
- **Group-based memory:** In OpenClaw, all groups under one agent share the same SOUL.md, MEMORY.md, and IDENTITY.md. In NanoClaw, each group has its own filesystem and CLAUDE.md. Shared state goes in `groups/global/CLAUDE.md` (mounted read-only into all non-main containers).
|
||||
- **Channel skills:** In OpenClaw, channels are configured in `openclaw.json`. In NanoClaw, channels are installed as code via skills (`/add-telegram`, `/add-whatsapp`, etc.) and configured through `.env` variables.
|
||||
- **Simpler config:** NanoClaw has no config file — behavior is in the code and `CLAUDE.md` files. Credentials live in `.env` or the OneCLI vault.
|
||||
|
||||
AskUserQuestion: "Ready to start migrating? I'll go through each area one at a time."
|
||||
1. **Yes, let's go** — proceed to Phase 1
|
||||
2. **Tell me more** — explain more about any area they ask about
|
||||
3. **Skip migration** — exit
|
||||
|
||||
## Phase 1: Groups and Architecture
|
||||
|
||||
**This discussion must happen before identity/memory, because the shared-vs-isolated decision determines where files go.**
|
||||
|
||||
If GROUP_COUNT > 0 or AGENT_COUNT > 1, this is a critical conversation. Even with just one group, explain the model difference so the user understands what they're getting into.
|
||||
|
||||
**OpenClaw model:** All groups routed to the same agent share one workspace — the same SOUL.md, MEMORY.md, IDENTITY.md, and tools. When you talk to the bot in your family chat or your work chat, it's the same agent with the same personality and memory. Only the session (conversation history) is separate per group.
|
||||
|
||||
**NanoClaw model:** Each group is a completely separate agent running in its own Linux container. Separate filesystem, separate memory, separate CLAUDE.md. The bot in your family chat and your work chat are different agents that don't know about each other — unless you explicitly share state via `groups/global/CLAUDE.md`, which is mounted read-only into all non-main containers.
|
||||
|
||||
Explain this conversationally. If the user only has one group, it's simple — just note the difference and move on. If they have multiple groups, discuss:
|
||||
|
||||
AskUserQuestion: "In OpenClaw, your groups shared the same personality and memory. In NanoClaw, each group is a fully separate agent. How would you like to handle this?"
|
||||
|
||||
1. **Shared personality (recommended if your groups had the same bot)** — "I'll put the shared personality, identity, and user context in `groups/global/CLAUDE.md`. Every group sees it. Each group can add its own customizations on top."
|
||||
2. **Fully separate** — "Each group gets its own independent personality and memory. Complete isolation between groups."
|
||||
3. **Just main group for now** — "Set up one group now. We can add others later."
|
||||
|
||||
Remember this choice — it determines where identity and memory files go in the next phase.
|
||||
|
||||
### Confirm assistant name
|
||||
|
||||
Before registering groups, confirm the assistant name — it's used for trigger patterns and CLAUDE.md templates.
|
||||
|
||||
IDENTITY_NAME from discovery gives the OpenClaw name. Ask the user: "Your OpenClaw assistant was named `<IDENTITY_NAME>`. Want to keep this name in NanoClaw?" If they want a different name, ask what it should be. If IDENTITY_NAME was empty, ask them to choose a name (default: "Andy").
|
||||
|
||||
The register step's `--assistant-name` flag writes `ASSISTANT_NAME` to `.env` and updates CLAUDE.md templates automatically — no manual `.env` write needed.
|
||||
|
||||
### Registering groups
|
||||
|
||||
The discovery script provides detected groups in the GROUPS field (format: `channel:id(name)=>nanoclaw_jid`). These are extracted from OpenClaw's session store and channel config.
|
||||
|
||||
For each group the user wants to bring over, pre-register it:
|
||||
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "<nanoclaw_jid>" --name "<group_name>" --folder "<channel>_<slug>" --trigger "@<confirmed_name>" --channel <channel> --assistant-name "<confirmed_name>"
|
||||
```
|
||||
|
||||
Only pass `--assistant-name` on the first registration (it updates all CLAUDE.md templates globally).
|
||||
|
||||
Folder naming: `<channel>_<name-slug>` (e.g. `whatsapp_family-chat`, `telegram_dev-team`). Ask the user to confirm each group's name and folder.
|
||||
|
||||
For the first/primary group, add `--is-main --no-trigger-required`. Other groups default to requiring a trigger prefix.
|
||||
|
||||
**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `npx tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template.
|
||||
|
||||
Register groups from all channels — including channels NanoClaw doesn't yet support (signal, matrix, etc.). The registration stores the JID and metadata in the database, ready for when that channel is added later. Groups won't receive messages until their channel code is installed, but the registration, group folder, and CLAUDE.md will be ready.
|
||||
|
||||
## Phase 2: Settings from Config
|
||||
|
||||
Before identity/memory, extract settings from `openclaw.json` that map directly to NanoClaw setup. Read the config file with the Read tool (`<STATE_DIR>/openclaw.json` or `clawdbot.json`).
|
||||
|
||||
### Timezone
|
||||
|
||||
Check `agents.defaults.userTimezone` in the config. If present and it's a valid IANA timezone (e.g. `America/New_York`, `Asia/Jerusalem`), write it to `.env` as `TZ=<timezone>`. NanoClaw's setup step 2a reads `TZ` from `.env` (`src/config.ts:84-97`) and will skip the autodetection prompt.
|
||||
|
||||
### Anthropic Credentials
|
||||
|
||||
Check for Anthropic API keys or tokens in OpenClaw's auth system. OpenClaw stores credentials in `<STATE_DIR>/auth-profiles.json` or `<STATE_DIR>/agents/main/agent/auth-profiles.json` with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"anthropic:default": {
|
||||
"type": "api_key", // or "token" or "oauth"
|
||||
"provider": "anthropic",
|
||||
"key": "sk-ant-..." // for api_key type
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile IDs follow `provider:identifier` format. Look for any profile where `provider` is `"anthropic"`. The credential field depends on the `type`:
|
||||
- `type: "api_key"` → `key` field (or `keyRef` for SecretRef)
|
||||
- `type: "token"` → `token` field (or `tokenRef` for SecretRef)
|
||||
- `type: "oauth"` → `access` field (OAuth access token, may need refresh)
|
||||
|
||||
Also check:
|
||||
1. `<STATE_DIR>/.env` — for `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
2. Config `models.providers` — for Anthropic provider entries with `apiKey`
|
||||
|
||||
If found, offer to save to `.env`. This pre-fills the NanoClaw setup credential step (step 4) so the user doesn't need to re-enter it. Use the same masking approach — show first 4 + last 4 characters, write the full value directly.
|
||||
|
||||
**Important:** If the credential uses `keyRef`/`tokenRef` with `source:"exec"` or `source:"file"`, explain that it can't be auto-extracted and the user will need to enter it during setup. For `type: "oauth"` credentials with an expiry in the past, warn the user the token may need to be refreshed during setup.
|
||||
|
||||
### Sender Allowlists
|
||||
|
||||
Read the channel configs for access control settings. OpenClaw stores these per-channel:
|
||||
- `channels.<channel>.allowFrom` — array of allowed sender IDs (E.164 for WhatsApp, numeric IDs for Telegram)
|
||||
- `channels.<channel>.dmPolicy` — `"open"`, `"allowlist"`, `"disabled"`
|
||||
- `channels.<channel>.groupPolicy` — `"open"`, `"allowlist"`, `"disabled"`
|
||||
- `channels.<channel>.groupAllowFrom` — array of allowed group member IDs
|
||||
|
||||
NanoClaw uses `~/.config/nanoclaw/sender-allowlist.json` with this format:
|
||||
```json
|
||||
{
|
||||
"default": { "allow": "*", "mode": "trigger" },
|
||||
"chats": {
|
||||
"<chat-jid>": {
|
||||
"allow": ["sender-id-1", "sender-id-2"],
|
||||
"mode": "trigger"
|
||||
}
|
||||
},
|
||||
"logDenied": true
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `allow`: `"*"` (all senders) or `string[]` (specific sender IDs)
|
||||
- `mode`: `"trigger"` (messages stored but trigger blocked for non-allowed senders) or `"drop"` (messages silently discarded before storage)
|
||||
- `logDenied`: optional boolean (default `true`), logs denied messages
|
||||
|
||||
If OpenClaw had allowlists configured, show the user what was set and offer to create the NanoClaw equivalent. Map:
|
||||
- `dmPolicy:"allowlist"` + `allowFrom` → per-chat entry with `"allow"` array, `"mode": "trigger"`
|
||||
- `groupPolicy:"allowlist"` + `groupAllowFrom` → per-group entry with `"allow"` array, `"mode": "trigger"`
|
||||
- `dmPolicy:"open"` → `"allow": "*"`
|
||||
- `dmPolicy:"disabled"` → per-chat entry with `"allow": []`, `"mode": "drop"` (or don't register that chat)
|
||||
|
||||
Create the directory and file:
|
||||
```bash
|
||||
mkdir -p ~/.config/nanoclaw
|
||||
```
|
||||
|
||||
Then write the JSON file. If no allowlists were configured, skip this.
|
||||
|
||||
### Container Timeout
|
||||
|
||||
Check `agents.defaults.timeoutSeconds` in the config. This is maximum total agent runtime (wall-clock). NanoClaw's equivalent is `CONTAINER_TIMEOUT` (env var, default 30 min), also configurable per-group via `containerConfig.timeout`. Note: NanoClaw also has a separate `IDLE_TIMEOUT` (max time without output) which resets on activity — OpenClaw has no equivalent.
|
||||
|
||||
If the OpenClaw value differs significantly from 30 minutes, note it for the user. They can set `CONTAINER_TIMEOUT=<ms>` in `.env` after setup.
|
||||
|
||||
## Phase 3: Identity and Memory
|
||||
|
||||
This phase is fully conversational — read files directly and discuss with the user. No script needed.
|
||||
|
||||
**Where files go depends on the Phase 1 (groups) decision:**
|
||||
- **Shared personality:** Core identity goes in `groups/global/CLAUDE.md` (seen by all groups). Group-specific customizations go in each group's own CLAUDE.md.
|
||||
- **Fully separate:** Everything goes in `groups/main/` (or each group's own folder).
|
||||
- **Just main group:** Everything goes in `groups/main/`.
|
||||
|
||||
### Find workspace files
|
||||
|
||||
The STATE_DIR from discovery tells you where OpenClaw lives. Look for workspace files at `<STATE_DIR>/workspace/`. If AGENT_COUNT > 1, also check `<STATE_DIR>/agents/*/workspace/` and ask which agent to migrate.
|
||||
|
||||
Use the Read tool to look at each file found.
|
||||
|
||||
### IDENTITY.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/IDENTITY.md` if it exists. It uses a key:value format (name, emoji, creature, vibe, etc.).
|
||||
|
||||
The assistant name was already confirmed and written to `.env` in Phase 1. Here, focus on the rest of the identity — create an `identity.md` file with the full identity details (emoji, creature, vibe, personality traits, etc.). If shared personality was chosen in Phase 1, put it alongside `groups/global/CLAUDE.md`. Otherwise, put it in `groups/main/`.
|
||||
|
||||
### SOUL.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/SOUL.md` if it exists. Then read `groups/main/CLAUDE.md`.
|
||||
|
||||
CLAUDE.md is always loaded into the agent's context — it's the agent's continuous instructions. Not everything from SOUL.md needs to be there. Discuss with the user what belongs where:
|
||||
|
||||
- **In CLAUDE.md (always loaded):** Core personality traits, communication style, key behavioral rules. Weave these into the existing CLAUDE.md structure — adjust the opening description under the `# <Name>` heading, modify the tone in the Communication section.
|
||||
- **In a separate soul file:** Detailed personality backstory, extended guidelines, creative writing style, philosophical grounding — things the agent can reference when relevant but don't need to consume context tokens on every turn.
|
||||
|
||||
**File placement depends on Phase 1 choice:**
|
||||
- Shared personality → edit `groups/global/CLAUDE.md` for the core traits, create `groups/global/soul.md` for the extended content. All groups will see both.
|
||||
- Separate / main only → edit `groups/main/CLAUDE.md`, create `groups/main/soul.md`.
|
||||
|
||||
Add a reference in the relevant CLAUDE.md: "Your personality and extended behavioral guidelines are in `soul.md`. Refer to it for identity questions or when crafting responses that need your full character."
|
||||
|
||||
Show proposed edits to the user before applying. This is a thoughtful merge, not a copy-paste.
|
||||
|
||||
### USER.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/USER.md` if it exists.
|
||||
|
||||
Create `groups/main/user-context.md` with the user information. Add a reference in CLAUDE.md: "Information about your user is in `user-context.md`. Read it when you need context about who you're talking to."
|
||||
|
||||
Ask if they want any critical user facts (name, timezone, key preferences) directly in CLAUDE.md for always-on awareness.
|
||||
|
||||
### MEMORY.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/MEMORY.md` if it exists.
|
||||
|
||||
Show the contents and discuss what's worth keeping. Some memory entries may be stale or OpenClaw-specific. Create `groups/main/memories.md` for relevant items. Add a reference in CLAUDE.md.
|
||||
|
||||
### Daily memory files (`workspace/memory/*.md`)
|
||||
|
||||
If DAILY_MEMORY_FILES > 0 in the discovery output, OpenClaw accumulated dated memory files (e.g. `2024-01-01.md`). These contain observations, facts, and context gathered over time.
|
||||
|
||||
AskUserQuestion: "You have N daily memory files from OpenClaw. How would you like to handle them?"
|
||||
|
||||
1. **Copy as-is (recommended for many files)** — "I'll create a `daily-memories/` folder in your group directory and copy them over. Your agent can reference them when needed."
|
||||
- Create the folder in the appropriate group directory (per Phase 1 decision)
|
||||
- Copy all `.md` files: `cp -r <workspace>/memory/*.md <group_dir>/daily-memories/`
|
||||
- Add a reference in CLAUDE.md: "Historical daily memory files from your previous system are in `daily-memories/`. Refer to them when you need context about past events or observations."
|
||||
|
||||
2. **Consolidate into memories** — "I'll read through them, extract the durable facts, and add them to your memories file. This reduces clutter but takes longer."
|
||||
- Read each file, extract entries worth keeping (skip transient observations, focus on durable facts about the user, preferences, recurring topics)
|
||||
- Consolidate into `memories.md`
|
||||
- Use sub-agents for large volumes (>10 files)
|
||||
|
||||
3. **Skip** — "Don't bring daily memories over."
|
||||
|
||||
### OpenClaw Skills
|
||||
|
||||
If SKILL_COUNT > 0 in discovery, OpenClaw had custom skills. The SKILL.md format is a shared standard — skills are directly portable.
|
||||
|
||||
The discovery reports skill names and source locations. For each skill, read just the YAML front matter (name + description at the top of SKILL.md) and present a list to the user: skill name, description, source location. Let the user select which ones to bring over.
|
||||
|
||||
For confirmed skills, copy the entire skill directory as-is:
|
||||
|
||||
```bash
|
||||
cp -r <skill_source_dir> container/skills/<skill_name>
|
||||
```
|
||||
|
||||
After all skills are copied, a container rebuild is needed — note this for post-migration: `./container/build.sh`.
|
||||
|
||||
### Config-registered plugins and skills
|
||||
|
||||
If CONFIG_PLUGIN_COUNT > 0 in discovery, OpenClaw had installed plugins/skills with API keys (e.g. `plugins.entries.brave`, `skills.entries.openai-whisper-api`). These are functional tools the agent had access to.
|
||||
|
||||
For each detected plugin, present the name to the user and discuss whether to set it up in NanoClaw. Read the OpenClaw config section to understand what it is, then:
|
||||
|
||||
1. **If NanoClaw has a matching skill** — check the available NanoClaw skills list for an equivalent (e.g. `/add-voice-transcription` for whisper). If found, save the API key to `.env` and invoke that skill.
|
||||
|
||||
2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `npx -y <exact-package-from-config>`). Don't search for or guess at MCP packages — only install what was explicitly configured.
|
||||
|
||||
3. **If the OpenClaw plugin was a CLI tool** — read the config to identify the exact tool. If it's an npm package, add it to the container's Dockerfile. Add a note to the group's CLAUDE.md that the tool is available and how to invoke it.
|
||||
|
||||
4. **If the plugin wraps an API** — discuss with the user what it did and offer to implement the equivalent: save the API key to `.env`, write a container skill with instructions for using the API, or wire it into the message flow if it's something automatic (e.g. voice transcription).
|
||||
|
||||
5. **If unclear** — discuss with the user what the plugin did and decide together. Don't install unknown packages or search for replacements — that's a supply chain risk.
|
||||
|
||||
For API keys, read the config value directly (don't display raw keys) and write to `.env`. The discovery script reports which plugins have keys but never extracts them.
|
||||
|
||||
### Other files (TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md, AGENTS.md)
|
||||
|
||||
If these exist, briefly mention them and explain:
|
||||
- TOOLS.md: NanoClaw agents have their own tool discovery; this doesn't transfer
|
||||
- HEARTBEAT.md: NanoClaw uses scheduled tasks instead
|
||||
- BOOTSTRAP.md: NanoClaw uses CLAUDE.md and container skills instead
|
||||
- AGENTS.md: Already covered in the Phase 1 groups discussion
|
||||
|
||||
## Phase 4: Channel Credentials
|
||||
|
||||
For each channel found in the discovery results, handle it based on NanoClaw support:
|
||||
|
||||
### Supported channels (whatsapp, telegram, slack, discord)
|
||||
|
||||
Run the credential extraction script with `--write-env .env` so it writes credentials directly to NanoClaw's `.env` file. The script never emits raw credential values to stdout — only masked versions.
|
||||
|
||||
First, run without `--write-env` to preview:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir <STATE_DIR> --channel <name>
|
||||
```
|
||||
|
||||
Parse the status block. Key fields: HAS_CREDENTIAL, CREDENTIAL_MASKED, NANOCLAW_ENV_VAR.
|
||||
|
||||
**If HAS_CREDENTIAL=false but the user expects a credential:** The extraction script may not recognize the config structure. Fall back to reading the channel section of `openclaw.json` directly with the Read tool and look for any field that contains a token or key value. Ask the user to confirm.
|
||||
|
||||
If HAS_CREDENTIAL=true: Show the masked credential (`CREDENTIAL_MASKED`). AskUserQuestion:
|
||||
1. **Use this credential** — run again with `--write-env .env` to save it
|
||||
2. **Enter a new one** — ask in plain text, write to `.env` manually
|
||||
3. **Skip this channel** — don't configure
|
||||
|
||||
If using the credential:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir <STATE_DIR> --channel <name> --write-env .env
|
||||
```
|
||||
|
||||
The script writes the credential directly to `.env` using the correct NanoClaw variable name (e.g. `TELEGRAM_BOT_TOKEN`). Check the status block for `WRITTEN_TO` and `WRITTEN_COUNT` to confirm.
|
||||
|
||||
**Credential destination note:** Credentials are saved to `.env` for now. During `/setup`, the credential step will either keep them in `.env` (Apple Container) or migrate them to the OneCLI vault (Docker). The user doesn't need to worry about this now.
|
||||
|
||||
For Slack: there are two credentials (bot token + app token). The script handles both in one run — check `HAS_CREDENTIAL_2` and `NANOCLAW_ENV_VAR_2` in the status block.
|
||||
|
||||
**WhatsApp special case:** WhatsApp uses QR/pairing-code authentication, not a token. Do not copy auth state from OpenClaw — encryption sessions become stale after copying and messages fail to decrypt. Authentication will be handled during `/setup` via the `/add-whatsapp` skill (takes about 60 seconds with a pairing code). Just note that WhatsApp was configured and move on.
|
||||
|
||||
**Allowlist note:** If the channel had `allowFrom` or group policies, these were already handled in Phase 2 (sender allowlists). Mention that the allowlist file was created earlier.
|
||||
|
||||
### Unsupported channels (signal, matrix, irc, msteams, feishu, etc.)
|
||||
|
||||
Explain briefly: "NanoClaw doesn't have a `<channel>` integration yet, but channels are added over time via skills. Any groups from this channel were already registered in Phase 1 — they'll activate when the channel is added."
|
||||
|
||||
If there are credentials (tokens, keys) for the unsupported channel, offer to save them to `.env` with a descriptive variable name (e.g. `SIGNAL_ACCOUNT`, `MATRIX_ACCESS_TOKEN`) so they're available when the channel is eventually supported.
|
||||
|
||||
Don't invoke channel skills here — just prepare `.env` credentials. Channel code is installed during `/setup`.
|
||||
|
||||
## Phase 5: Scheduled Tasks
|
||||
|
||||
Read `<STATE_DIR>/cron/jobs.json` with the Read tool. If the file doesn't exist or has no jobs, skip this phase.
|
||||
|
||||
If jobs exist, read `${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md` for the full OpenClaw cron format, NanoClaw table schema, field mapping, and SQL insert template. Follow those instructions for each job.
|
||||
|
||||
## Phase 6: Webhooks, MCP, and Other Config
|
||||
|
||||
Read relevant sections from `<STATE_DIR>/openclaw.json` directly with the Read tool. This phase is fully conversational.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
If MCP_SERVERS was non-empty in discovery, these can be ported. Claude Code supports MCP servers natively. Read the OpenClaw config's `mcp.servers` section to get each server's details (`command`, `args`, `env`, `url`).
|
||||
|
||||
MCP servers in NanoClaw are registered in the agent-runner source code. Before editing, grep for `mcpServers` in `container/agent-runner/src/` to find the current location — it's expected to be in `index.ts` in the `query()` options, but may have moved. For each OpenClaw MCP server the user wants to bring over:
|
||||
|
||||
1. Read its config: command, args, env, url
|
||||
2. **stdio servers** (have `command`): Add an entry to the `mcpServers` object in `container/agent-runner/src/index.ts`. The command runs inside the container, so it needs to be available there (Node.js/npx-based servers work; custom binaries would need to be added to the Dockerfile).
|
||||
3. **HTTP/SSE servers** (have `url`): These work if the URL is accessible from inside the container. Add them the same way.
|
||||
4. **Environment variables**: Any `env` values that reference secrets should be added to `.env` and passed through via `process.env.*` in the mcpServers entry.
|
||||
|
||||
After adding all MCP servers, a container rebuild is needed: `./container/build.sh`
|
||||
|
||||
Show the user each server and ask which to bring over. For servers that need custom binaries not available in the container, note them for manual setup.
|
||||
|
||||
### Webhooks and Endpoints
|
||||
|
||||
If the config has webhook sections (in `cron.webhook`, `cron.failureDestination`, or channel-specific webhooks):
|
||||
- Explain what they were used for
|
||||
- These don't map directly but NanoClaw can be customized to support them
|
||||
- Discuss the use case with the user and propose a solution if it's important to them
|
||||
- For simple webhook notifications: a task script with `curl` often suffices
|
||||
|
||||
### Other Config
|
||||
|
||||
Scan the config for notable sections and briefly mention anything that doesn't carry over:
|
||||
- **Exec approvals / command allowlist:** NanoClaw uses container isolation instead — the agent runs with `--dangerously-skip-permissions` inside a sandboxed container
|
||||
- **Human delay:** Not applicable in NanoClaw's container model
|
||||
- **Compaction:** Handled by Claude Code SDK automatically
|
||||
- **TTS:** Not built into NanoClaw
|
||||
- **Model configuration:** NanoClaw uses whatever Anthropic model the credential provides access to
|
||||
|
||||
Don't belabor these — just mention and move on.
|
||||
|
||||
## Phase 7: Summary
|
||||
|
||||
### Summary
|
||||
|
||||
Print a comprehensive summary:
|
||||
|
||||
**Migrated:**
|
||||
- Assistant name → `.env` ASSISTANT_NAME + CLAUDE.md templates updated
|
||||
- Groups → registered in database, folders created with CLAUDE.md templates
|
||||
- Timezone → `.env` TZ
|
||||
- Anthropic credential → `.env` (for setup to pick up)
|
||||
- Sender allowlists → `~/.config/nanoclaw/sender-allowlist.json`
|
||||
- Personality → CLAUDE.md (core) + `soul.md` (extended), placed per Phase 1 decision (global or per-group)
|
||||
- User context → `user-context.md`
|
||||
- Memories → `memories.md` + daily memory files (copied to `daily-memories/` or consolidated)
|
||||
- OpenClaw skills → copied to `container/skills/`
|
||||
- Channel credentials → `.env` (list which channels)
|
||||
- Scheduled tasks → inserted into database or noted for post-setup
|
||||
- MCP servers → registered in agent-runner
|
||||
|
||||
**Noted for later:**
|
||||
- Channel code installation (happens during `/setup`)
|
||||
- Task creation (if deferred due to no registered group yet)
|
||||
- Container rebuild needed (if skills or MCP servers were added): `./container/build.sh`
|
||||
|
||||
**Not applicable:**
|
||||
- Unsupported channels (list them — groups registered for future)
|
||||
- OpenClaw-specific features (exec approvals, human delay, TTS, model config, session reset policies, etc.)
|
||||
|
||||
**Discussed and deferred:**
|
||||
- List any customizations agreed on but not yet implemented
|
||||
|
||||
Remind: "Run `/setup` next to complete your NanoClaw installation. Channel credentials are already prepared in `.env`. When setup asks which channels to enable, select the ones we configured."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Config parse error:** If `openclaw.json` fails to parse, it may use JSON5 features the parser doesn't handle. Ask the user to check the file for unusual syntax. As a fallback, the agent can read the file directly and work with it manually.
|
||||
|
||||
**Credential not found:** If a channel credential resolves to empty, it may use `source:"exec"` or `source:"file"` SecretRef. These can't be auto-extracted. Ask the user to provide the value directly.
|
||||
|
||||
**Multi-agent complexity:** If the user had many agents with different configs, focus on the primary/default agent first. Additional agents can be set up as separate NanoClaw groups later.
|
||||
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* Discover an existing OpenClaw installation and emit a structured summary.
|
||||
*
|
||||
* Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir <path>]
|
||||
*
|
||||
* Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot
|
||||
* Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files,
|
||||
* checks cron jobs, MCP servers, and channel credentials.
|
||||
*
|
||||
* Emits a status block on stdout:
|
||||
* === NANOCLAW MIGRATE: DISCOVERY ===
|
||||
* ...
|
||||
* === END ===
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON5-tolerant parser (no dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseJson5(text: string): unknown {
|
||||
// Strip single-line comments (// ...) that aren't inside strings
|
||||
let cleaned = text.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
// Strip block comments (/* ... */)
|
||||
cleaned = cleaned.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
// Strip trailing commas before } or ]
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status block emitter (mirrors setup/status.ts convention)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emitStatus(fields: Record<string, string | number | boolean>): void {
|
||||
const lines = ['=== NANOCLAW MIGRATE: DISCOVERY ==='];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
lines.push('=== END ===');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arg parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(): { stateDir?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--state-dir' && args[i + 1]) {
|
||||
return { stateDir: args[i + 1] };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveStateDir(explicit?: string): string | null {
|
||||
const home = os.homedir();
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (explicit) {
|
||||
// Expand ~ prefix
|
||||
const expanded = explicit.startsWith('~')
|
||||
? path.join(home, explicit.slice(1))
|
||||
: explicit;
|
||||
candidates.push(expanded);
|
||||
}
|
||||
|
||||
if (process.env.OPENCLAW_STATE_DIR) {
|
||||
candidates.push(process.env.OPENCLAW_STATE_DIR);
|
||||
}
|
||||
|
||||
candidates.push(path.join(home, '.openclaw'));
|
||||
candidates.push(path.join(home, '.clawdbot'));
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadConfig(
|
||||
stateDir: string,
|
||||
): Record<string, unknown> | null {
|
||||
for (const name of ['openclaw.json', 'clawdbot.json']) {
|
||||
const configPath = path.join(stateDir, name);
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
return parseJson5(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Try next name
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChannelInfo {
|
||||
name: string;
|
||||
hasCreds: boolean;
|
||||
}
|
||||
|
||||
const SUPPORTED_CHANNELS = new Set([
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
'slack',
|
||||
'discord',
|
||||
]);
|
||||
|
||||
// Fields that indicate a credential is present for each channel
|
||||
const CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
telegram: ['botToken'],
|
||||
discord: ['token'],
|
||||
slack: ['botToken', 'appToken'],
|
||||
whatsapp: [], // Auth-state based, no token
|
||||
signal: ['account'],
|
||||
imessage: [],
|
||||
matrix: ['homeserverUrl', 'accessToken'],
|
||||
irc: ['server'],
|
||||
msteams: ['appId'],
|
||||
feishu: ['appId'],
|
||||
googlechat: [],
|
||||
mattermost: ['token', 'url'],
|
||||
zalo: [],
|
||||
bluebubbles: ['url'],
|
||||
};
|
||||
|
||||
const ALL_KNOWN_CHANNELS = new Set([
|
||||
'whatsapp', 'telegram', 'slack', 'discord', 'signal',
|
||||
'imessage', 'matrix', 'irc', 'msteams', 'feishu',
|
||||
'googlechat', 'mattermost', 'zalo', 'bluebubbles',
|
||||
]);
|
||||
|
||||
function detectChannels(
|
||||
config: Record<string, unknown>,
|
||||
): ChannelInfo[] {
|
||||
// Check both config.channels.* (newer) and top-level config.* (older/legacy)
|
||||
const channelsSections: Record<string, unknown> = {};
|
||||
|
||||
// Source 1: channels.* (standard location)
|
||||
const nested = config.channels as Record<string, unknown> | undefined;
|
||||
if (nested) {
|
||||
for (const [k, v] of Object.entries(nested)) {
|
||||
if (v && typeof v === 'object') channelsSections[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: top-level keys matching known channel names (legacy format)
|
||||
for (const key of Object.keys(config)) {
|
||||
if (ALL_KNOWN_CHANNELS.has(key) && !channelsSections[key]) {
|
||||
const v = config[key];
|
||||
if (v && typeof v === 'object') channelsSections[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const results: ChannelInfo[] = [];
|
||||
|
||||
for (const [name, section] of Object.entries(channelsSections)) {
|
||||
if (!section || typeof section !== 'object') continue;
|
||||
const ch = section as Record<string, unknown>;
|
||||
|
||||
// Check if any credential field is present and non-empty
|
||||
const credFields = CREDENTIAL_FIELDS[name] ?? [];
|
||||
let hasCreds = false;
|
||||
|
||||
for (const field of credFields) {
|
||||
const val = ch[field];
|
||||
if (val && (typeof val === 'string' || typeof val === 'object')) {
|
||||
hasCreds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check accounts for multi-account setups
|
||||
if (!hasCreds && ch.accounts && typeof ch.accounts === 'object') {
|
||||
for (const acct of Object.values(
|
||||
ch.accounts as Record<string, unknown>,
|
||||
)) {
|
||||
if (!acct || typeof acct !== 'object') continue;
|
||||
const a = acct as Record<string, unknown>;
|
||||
for (const field of credFields) {
|
||||
if (
|
||||
a[field] &&
|
||||
(typeof a[field] === 'string' || typeof a[field] === 'object')
|
||||
) {
|
||||
hasCreds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasCreds) break;
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp: check for auth state directory instead of token
|
||||
if (name === 'whatsapp' && !hasCreds) {
|
||||
// Will be checked separately via agents directory
|
||||
hasCreds = false;
|
||||
}
|
||||
|
||||
results.push({ name, hasCreds });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACE_FILES = [
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'MEMORY.md',
|
||||
'IDENTITY.md',
|
||||
'TOOLS.md',
|
||||
'HEARTBEAT.md',
|
||||
'BOOTSTRAP.md',
|
||||
'AGENTS.md',
|
||||
];
|
||||
|
||||
function findWorkspace(stateDir: string, config: Record<string, unknown> | null): {
|
||||
dir: string | null;
|
||||
files: string[];
|
||||
} {
|
||||
// Check config-specified workspace path first (agent.workspace or agents.defaults.workspace)
|
||||
const configPaths: string[] = [];
|
||||
if (config) {
|
||||
const agentWs = (config.agent as Record<string, unknown> | undefined)?.workspace as string | undefined;
|
||||
if (agentWs) configPaths.push(agentWs.startsWith('~') ? path.join(os.homedir(), agentWs.slice(1)) : agentWs);
|
||||
const defaultsWs = ((config.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined)?.workspace as string | undefined;
|
||||
if (defaultsWs) configPaths.push(defaultsWs.startsWith('~') ? path.join(os.homedir(), defaultsWs.slice(1)) : defaultsWs);
|
||||
}
|
||||
|
||||
// Check config-specified paths, then default locations
|
||||
const candidates = [
|
||||
...configPaths,
|
||||
...['workspace', 'workspace.default'].map((n) => path.join(stateDir, n)),
|
||||
];
|
||||
|
||||
for (const ws of candidates) {
|
||||
if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
|
||||
const found = WORKSPACE_FILES.filter((f) =>
|
||||
fs.existsSync(path.join(ws, f)),
|
||||
);
|
||||
if (found.length > 0) {
|
||||
return { dir: ws, files: found };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check agent-specific workspaces
|
||||
const agentsDir = path.join(stateDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
for (const wsName of ['workspace', 'workspace.default']) {
|
||||
const ws = path.join(agentsDir, agentId, wsName);
|
||||
if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
|
||||
const found = WORKSPACE_FILES.filter((f) =>
|
||||
fs.existsSync(path.join(ws, f)),
|
||||
);
|
||||
if (found.length > 0) {
|
||||
return { dir: ws, files: found };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dir: null, files: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily memory file detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countDailyMemoryFiles(workspaceDir: string | null): number {
|
||||
if (!workspaceDir) return 0;
|
||||
const memoryDir = path.join(workspaceDir, 'memory');
|
||||
if (!fs.existsSync(memoryDir) || !fs.statSync(memoryDir).isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(memoryDir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
source: string; // 'workspace' | 'shared' | 'personal' | 'project'
|
||||
path: string;
|
||||
}
|
||||
|
||||
function detectSkills(
|
||||
stateDir: string,
|
||||
workspaceDir: string | null,
|
||||
): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const scanDir = (dir: string, source: string) => {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const skillDir = path.join(dir, entry);
|
||||
if (!fs.statSync(skillDir).isDirectory()) continue;
|
||||
// A directory is a skill if it contains SKILL.md
|
||||
if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) {
|
||||
if (seen.has(entry)) continue;
|
||||
seen.add(entry);
|
||||
skills.push({ name: entry, source, path: skillDir });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Workspace skills
|
||||
if (workspaceDir) {
|
||||
scanDir(path.join(workspaceDir, 'skills'), 'workspace');
|
||||
// 4. Project-level shared skills
|
||||
scanDir(path.join(workspaceDir, '.agents', 'skills'), 'project');
|
||||
}
|
||||
|
||||
// 2. Managed/shared skills
|
||||
scanDir(path.join(stateDir, 'skills'), 'shared');
|
||||
|
||||
// 3. Personal cross-project skills
|
||||
const personalSkills = path.join(os.homedir(), '.agents', 'skills');
|
||||
scanDir(personalSkills, 'personal');
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractIdentityName(stateDir: string, workspaceDir: string | null): string {
|
||||
if (!workspaceDir) return '';
|
||||
|
||||
const identityPath = path.join(workspaceDir, 'IDENTITY.md');
|
||||
if (!fs.existsSync(identityPath)) return '';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, 'utf-8');
|
||||
// IDENTITY.md uses key:value format, e.g. "name: Claw"
|
||||
const match = content.match(/^name:\s*(.+)/im);
|
||||
return match ? match[1].trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectAgents(stateDir: string): string[] {
|
||||
const agentsDir = path.join(stateDir, 'agents');
|
||||
if (!fs.existsSync(agentsDir)) return [];
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(agentsDir)
|
||||
.filter((f) => {
|
||||
const p = path.join(agentsDir, f);
|
||||
return fs.statSync(p).isDirectory() && !f.startsWith('.');
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group detection — from session store and channel config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupInfo {
|
||||
channel: string;
|
||||
id: string; // Platform-specific ID (WhatsApp JID, Telegram chat ID, etc.)
|
||||
name: string;
|
||||
source: 'session' | 'config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenClaw session key channel:kind:id to NanoClaw JID format.
|
||||
* OpenClaw keys: "whatsapp:group:120...@g.us", "telegram:group:-10012345"
|
||||
* NanoClaw JIDs: "120...@g.us", "tg:-10012345", "dc:12345", "slack:C12345"
|
||||
*/
|
||||
function toNanoClawJid(channel: string, id: string): string {
|
||||
switch (channel) {
|
||||
case 'whatsapp':
|
||||
return id; // Already in JID format (120...@g.us)
|
||||
case 'telegram':
|
||||
return `tg:${id}`;
|
||||
case 'discord':
|
||||
return `dc:${id}`;
|
||||
case 'slack':
|
||||
return `slack:${id}`;
|
||||
default:
|
||||
return `${channel}:${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
function detectGroups(
|
||||
stateDir: string,
|
||||
config: Record<string, unknown> | null,
|
||||
agents: string[],
|
||||
): GroupInfo[] {
|
||||
const groups: GroupInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Source 1: Session store — scan for group session keys
|
||||
for (const agentId of agents) {
|
||||
const sessionsPath = path.join(
|
||||
stateDir,
|
||||
'agents',
|
||||
agentId,
|
||||
'sessions',
|
||||
'sessions.json',
|
||||
);
|
||||
if (!fs.existsSync(sessionsPath)) continue;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(sessionsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
// Sessions can be stored as an object with session keys, or as
|
||||
// { sessions: { key: entry } } or { entries: [...] }
|
||||
const entries =
|
||||
(data.sessions as Record<string, unknown>) ??
|
||||
(data.entries as Record<string, unknown>) ??
|
||||
data;
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
// Match session keys like "whatsapp:group:120...@g.us"
|
||||
// or prefixed "agent:main:whatsapp:group:120...@g.us"
|
||||
// Also match DM sessions: "whatsapp:dm:number@s.whatsapp.net"
|
||||
const match = key.match(/(\w+):(group|dm|channel):(.+)$/i);
|
||||
if (!match) continue;
|
||||
|
||||
const [, channel, kind, id] = match;
|
||||
// Skip DM sessions for group detection — they're individual chats
|
||||
if (kind === 'dm') continue;
|
||||
const dedupKey = `${channel}:${id}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
// Try to extract display name from session entry
|
||||
let name = '';
|
||||
if (value && typeof value === 'object') {
|
||||
const entry = value as Record<string, unknown>;
|
||||
name =
|
||||
(entry.displayName as string) ??
|
||||
(entry.label as string) ??
|
||||
(entry.subject as string) ??
|
||||
'';
|
||||
}
|
||||
|
||||
groups.push({
|
||||
channel,
|
||||
id,
|
||||
name: name || id,
|
||||
source: 'session',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: Channel config — groups explicitly configured
|
||||
if (config) {
|
||||
const channels =
|
||||
(config.channels as Record<string, unknown> | undefined) ?? {};
|
||||
for (const [channelName, channelSection] of Object.entries(channels)) {
|
||||
if (!channelSection || typeof channelSection !== 'object') continue;
|
||||
const ch = channelSection as Record<string, unknown>;
|
||||
|
||||
// WhatsApp/Telegram: channels.<channel>.groups.<groupId>
|
||||
const configGroups = ch.groups as Record<string, unknown> | undefined;
|
||||
if (configGroups) {
|
||||
for (const groupId of Object.keys(configGroups)) {
|
||||
const dedupKey = `${channelName}:${groupId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
groups.push({
|
||||
channel: channelName,
|
||||
id: groupId,
|
||||
name: groupId,
|
||||
source: 'config',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discord: channels.discord.guilds.<guildId>
|
||||
if (channelName === 'discord') {
|
||||
const guilds = ch.guilds as Record<string, unknown> | undefined;
|
||||
if (guilds) {
|
||||
for (const guildId of Object.keys(guilds)) {
|
||||
const dedupKey = `discord:${guildId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
groups.push({
|
||||
channel: 'discord',
|
||||
id: guildId,
|
||||
name: guildId,
|
||||
source: 'config',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron job counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countCronJobs(stateDir: string): {
|
||||
count: number;
|
||||
summaries: string[];
|
||||
} {
|
||||
const jobsPath = path.join(stateDir, 'cron', 'jobs.json');
|
||||
if (!fs.existsSync(jobsPath)) return { count: 0, summaries: [] };
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(jobsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as {
|
||||
jobs?: Array<{ name?: string; enabled?: boolean }>;
|
||||
};
|
||||
const jobs = data.jobs ?? [];
|
||||
const summaries = jobs
|
||||
.filter((j) => j.enabled !== false)
|
||||
.map((j) => j.name || 'unnamed')
|
||||
.slice(0, 10);
|
||||
return { count: jobs.length, summaries };
|
||||
} catch {
|
||||
return { count: 0, summaries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config-registered plugins and skills (with API keys)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConfigPlugin {
|
||||
name: string;
|
||||
source: 'skills.entries' | 'plugins.entries';
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
function detectConfigPlugins(
|
||||
config: Record<string, unknown>,
|
||||
): ConfigPlugin[] {
|
||||
const results: ConfigPlugin[] = [];
|
||||
|
||||
// Check skills.entries (e.g. openai-whisper-api with apiKey)
|
||||
const skills = config.skills as Record<string, unknown> | undefined;
|
||||
const skillEntries = skills?.entries as Record<string, unknown> | undefined;
|
||||
if (skillEntries) {
|
||||
for (const [name, entry] of Object.entries(skillEntries)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const hasKey = !!(e.apiKey || e.token || e.key);
|
||||
results.push({ name, source: 'skills.entries', hasApiKey: hasKey });
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugins.entries (e.g. brave with config.webSearch.apiKey)
|
||||
const plugins = config.plugins as Record<string, unknown> | undefined;
|
||||
const pluginEntries = plugins?.entries as Record<string, unknown> | undefined;
|
||||
if (pluginEntries) {
|
||||
for (const [name, entry] of Object.entries(pluginEntries)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
// Deep-search for apiKey in nested config
|
||||
const hasKey = JSON.stringify(entry).includes('apiKey');
|
||||
results.push({ name, source: 'plugins.entries', hasApiKey: hasKey });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectMcpServers(
|
||||
config: Record<string, unknown>,
|
||||
): string[] {
|
||||
const mcp = config.mcp as Record<string, unknown> | undefined;
|
||||
if (!mcp) return [];
|
||||
const servers = mcp.servers as Record<string, unknown> | undefined;
|
||||
if (!servers) return [];
|
||||
return Object.keys(servers);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const { stateDir: explicitDir } = parseArgs();
|
||||
const stateDir = resolveStateDir(explicitDir);
|
||||
|
||||
if (!stateDir) {
|
||||
emitStatus({ STATUS: 'not_found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(stateDir);
|
||||
const channels = config ? detectChannels(config) : [];
|
||||
const { dir: workspaceDir, files: workspaceFiles } =
|
||||
findWorkspace(stateDir, config);
|
||||
const identityName = extractIdentityName(stateDir, workspaceDir);
|
||||
const agents = detectAgents(stateDir);
|
||||
const groups = detectGroups(stateDir, config, agents);
|
||||
const { count: cronCount, summaries: cronSummaries } =
|
||||
countCronJobs(stateDir);
|
||||
const mcpServers = config ? detectMcpServers(config) : [];
|
||||
const dailyMemoryFiles = countDailyMemoryFiles(workspaceDir);
|
||||
const skills = detectSkills(stateDir, workspaceDir);
|
||||
const configPlugins = config ? detectConfigPlugins(config) : [];
|
||||
|
||||
// Format channels as "name(has_creds)" or "name(no_creds)"
|
||||
const channelList = channels
|
||||
.map((c) => `${c.name}(${c.hasCreds ? 'has_creds' : 'no_creds'})`)
|
||||
.join(',');
|
||||
|
||||
// Separate supported vs unsupported
|
||||
const unsupported = channels
|
||||
.filter((c) => !SUPPORTED_CHANNELS.has(c.name))
|
||||
.map((c) => c.name)
|
||||
.join(',');
|
||||
|
||||
// Format groups as "channel:id(name)" — also include NanoClaw JID mapping
|
||||
const groupList = groups
|
||||
.map(
|
||||
(g) =>
|
||||
`${g.channel}:${g.id}(${g.name})=>${toNanoClawJid(g.channel, g.id)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
// Format skills as "name(source)" list
|
||||
const skillList = skills
|
||||
.map((s) => `${s.name}(${s.source})`)
|
||||
.join(',');
|
||||
|
||||
// Dump raw top-level config keys so Claude can see what exists
|
||||
// beyond what this script specifically detects
|
||||
const configTopKeys = config ? Object.keys(config).sort().join(',') : 'none';
|
||||
const configChannelKeys = config?.channels
|
||||
? Object.keys(config.channels as Record<string, unknown>).sort().join(',')
|
||||
: 'none';
|
||||
|
||||
// List files/dirs at the state dir root for manual inspection
|
||||
let stateDirContents = 'unknown';
|
||||
try {
|
||||
stateDirContents = fs
|
||||
.readdirSync(stateDir)
|
||||
.filter((f) => !f.startsWith('.'))
|
||||
.sort()
|
||||
.join(',');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
emitStatus({
|
||||
STATUS: 'found',
|
||||
STATE_DIR: stateDir,
|
||||
CONFIG_FOUND: config !== null,
|
||||
CONFIG_TOP_KEYS: configTopKeys,
|
||||
CONFIG_CHANNEL_KEYS: configChannelKeys,
|
||||
STATE_DIR_CONTENTS: stateDirContents,
|
||||
CHANNELS: channelList || 'none',
|
||||
UNSUPPORTED_CHANNELS: unsupported || 'none',
|
||||
WORKSPACE_DIR: workspaceDir || 'not_found',
|
||||
WORKSPACE_FILES: workspaceFiles.join(',') || 'none',
|
||||
IDENTITY_NAME: identityName || 'unknown',
|
||||
AGENT_COUNT: agents.length,
|
||||
AGENT_IDS: agents.join(',') || 'none',
|
||||
GROUPS: groupList || 'none',
|
||||
GROUP_COUNT: groups.length,
|
||||
DAILY_MEMORY_FILES: dailyMemoryFiles,
|
||||
SKILL_COUNT: skills.length,
|
||||
SKILLS: skillList || 'none',
|
||||
CONFIG_PLUGINS: configPlugins.map((p) => `${p.name}(${p.source}${p.hasApiKey ? ',has_key' : ''})`).join(',') || 'none',
|
||||
CONFIG_PLUGIN_COUNT: configPlugins.length,
|
||||
CRON_JOBS: cronCount,
|
||||
CRON_SUMMARIES: cronSummaries.join('|') || 'none',
|
||||
MCP_SERVERS: mcpServers.join(',') || 'none',
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Extract a channel credential from an OpenClaw configuration and write it
|
||||
* directly to the NanoClaw .env file.
|
||||
*
|
||||
* Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \
|
||||
* --channel telegram --state-dir ~/.openclaw --write-env .env
|
||||
*
|
||||
* Handles OpenClaw SecretRef formats:
|
||||
* - Plain string: "bot-token-value"
|
||||
* - Env template: "${TELEGRAM_BOT_TOKEN}"
|
||||
* - SecretRef object: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }
|
||||
*
|
||||
* Also reads <state-dir>/.env for env-based secrets.
|
||||
*
|
||||
* Credential values are NEVER emitted to stdout — only masked versions.
|
||||
* When --write-env is provided, the script writes credentials directly to
|
||||
* the target .env file so the agent never sees raw secrets.
|
||||
*
|
||||
* Emits a status block on stdout:
|
||||
* === NANOCLAW MIGRATE: CREDENTIAL ===
|
||||
* ...
|
||||
* === END ===
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON5-tolerant parser (same as discover script)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseJson5(text: string): unknown {
|
||||
let cleaned = text.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
cleaned = cleaned.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline dotenv parser (reads key=value, skips comments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDotenv(filePath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (!fs.existsSync(filePath)) return env;
|
||||
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status block emitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emitStatus(fields: Record<string, string | number | boolean>): void {
|
||||
const lines = ['=== NANOCLAW MIGRATE: CREDENTIAL ==='];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
lines.push('=== END ===');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential masking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function maskCredential(value: string): string {
|
||||
if (value.length < 10) return '****';
|
||||
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SecretRef resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SecretRef {
|
||||
source: string;
|
||||
provider?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function resolveSecretInput(
|
||||
value: unknown,
|
||||
dotenvVars: Record<string, string>,
|
||||
): { resolved: string | null; source: string; note?: string } {
|
||||
if (!value) return { resolved: null, source: 'missing' };
|
||||
|
||||
// Plain string
|
||||
if (typeof value === 'string') {
|
||||
// Check for env template: "${VAR_NAME}"
|
||||
const envMatch = value.match(/^\$\{([^}]+)\}$/);
|
||||
if (envMatch) {
|
||||
const envKey = envMatch[1];
|
||||
const envVal =
|
||||
dotenvVars[envKey] ?? process.env[envKey] ?? null;
|
||||
if (envVal) {
|
||||
return { resolved: envVal, source: 'env_template' };
|
||||
}
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'env_template',
|
||||
note: `Environment variable ${envKey} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Plain literal value
|
||||
return { resolved: value, source: 'plain' };
|
||||
}
|
||||
|
||||
// SecretRef object
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const ref = value as SecretRef;
|
||||
if (ref.source === 'env') {
|
||||
const envVal =
|
||||
dotenvVars[ref.id] ?? process.env[ref.id] ?? null;
|
||||
if (envVal) {
|
||||
return { resolved: envVal, source: 'env_ref' };
|
||||
}
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'env_ref',
|
||||
note: `Environment variable ${ref.id} not found`,
|
||||
};
|
||||
}
|
||||
if (ref.source === 'file') {
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'file_ref',
|
||||
note: `File-based secret (${ref.id}) — cannot auto-extract, add manually`,
|
||||
};
|
||||
}
|
||||
if (ref.source === 'exec') {
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'exec_ref',
|
||||
note: `Exec-based secret (${ref.id}) — cannot auto-extract, add manually`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved: null, source: 'unknown' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel credential mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChannelCredentialSpec {
|
||||
// Fields to look for in the channel config
|
||||
fields: string[];
|
||||
// Corresponding NanoClaw env var names
|
||||
envVars: string[];
|
||||
}
|
||||
|
||||
const CHANNEL_SPECS: Record<string, ChannelCredentialSpec> = {
|
||||
telegram: {
|
||||
fields: ['botToken'],
|
||||
envVars: ['TELEGRAM_BOT_TOKEN'],
|
||||
},
|
||||
discord: {
|
||||
fields: ['token'],
|
||||
envVars: ['DISCORD_BOT_TOKEN'],
|
||||
},
|
||||
slack: {
|
||||
fields: ['botToken', 'appToken'],
|
||||
envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
||||
},
|
||||
whatsapp: {
|
||||
fields: [], // Auth-state based, no token field
|
||||
envVars: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arg parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(): { channel: string; stateDir: string; writeEnv: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let channel = '';
|
||||
let stateDir = '';
|
||||
let writeEnv = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--channel' && args[i + 1]) {
|
||||
channel = args[++i].toLowerCase();
|
||||
}
|
||||
if (args[i] === '--state-dir' && args[i + 1]) {
|
||||
stateDir = args[++i];
|
||||
}
|
||||
if (args[i] === '--write-env' && args[i + 1]) {
|
||||
writeEnv = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
console.error('Usage: --channel <name> --state-dir <path> [--write-env <path>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Expand ~ prefix
|
||||
if (stateDir.startsWith('~')) {
|
||||
stateDir = path.join(os.homedir(), stateDir.slice(1));
|
||||
}
|
||||
|
||||
// Default state dir
|
||||
if (!stateDir) {
|
||||
const home = os.homedir();
|
||||
if (fs.existsSync(path.join(home, '.openclaw'))) {
|
||||
stateDir = path.join(home, '.openclaw');
|
||||
} else if (fs.existsSync(path.join(home, '.clawdbot'))) {
|
||||
stateDir = path.join(home, '.clawdbot');
|
||||
} else {
|
||||
console.error(
|
||||
'No OpenClaw directory found. Use --state-dir to specify.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { channel, stateDir, writeEnv };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .env writer — appends or replaces a KEY=VALUE line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeEnvVar(envPath: string, key: string, value: string): void {
|
||||
let content = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
const line = `${key}="${value}"`;
|
||||
|
||||
if (pattern.test(content)) {
|
||||
content = content.replace(pattern, line);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + line + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const { channel, stateDir, writeEnv } = parseArgs();
|
||||
const spec = CHANNEL_SPECS[channel];
|
||||
|
||||
// Load dotenv from state dir
|
||||
const dotenvVars = parseDotenv(path.join(stateDir, '.env'));
|
||||
|
||||
// Also check auth-profiles.json for API keys
|
||||
const authProfilesPath = path.join(stateDir, 'auth-profiles.json');
|
||||
let authProfiles: Record<string, unknown> = {};
|
||||
if (fs.existsSync(authProfilesPath)) {
|
||||
try {
|
||||
authProfiles = JSON.parse(
|
||||
fs.readFileSync(authProfilesPath, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp special case: no token, auth-state based.
|
||||
// OpenClaw stores Baileys auth at <stateDir>/credentials/whatsapp/<accountId>/
|
||||
// using useMultiFileAuthState (same as NanoClaw). The files are directly compatible.
|
||||
if (channel === 'whatsapp') {
|
||||
const authPaths = [
|
||||
path.join(stateDir, 'credentials', 'whatsapp', 'default'),
|
||||
path.join(stateDir, 'credentials', 'whatsapp'),
|
||||
path.join(stateDir, 'wa-auth'),
|
||||
];
|
||||
|
||||
// Also scan credentials/whatsapp/ for any account subdirectory
|
||||
const waCredsDir = path.join(stateDir, 'credentials', 'whatsapp');
|
||||
if (fs.existsSync(waCredsDir)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(waCredsDir)) {
|
||||
const candidate = path.join(waCredsDir, entry);
|
||||
if (fs.statSync(candidate).isDirectory()) {
|
||||
authPaths.push(candidate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
let authStatePath = '';
|
||||
for (const p of authPaths) {
|
||||
// Look for creds.json inside the directory — that confirms valid Baileys auth state
|
||||
if (fs.existsSync(path.join(p, 'creds.json'))) {
|
||||
authStatePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
emitStatus({
|
||||
CHANNEL: 'whatsapp',
|
||||
HAS_CREDENTIAL: false,
|
||||
CREDENTIAL_SOURCE: 'auth_state',
|
||||
NOTE: authStatePath
|
||||
? `Baileys auth state found at ${authStatePath}. May not be portable across versions — recommend re-authenticating.`
|
||||
: 'No WhatsApp auth state found. Will need to authenticate during setup.',
|
||||
AUTH_STATE_PATH: authStatePath || 'not_found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown channel
|
||||
if (!spec) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: `Channel "${channel}" is not supported by NanoClaw. Supported: telegram, discord, slack, whatsapp.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Load OpenClaw config
|
||||
let config: Record<string, unknown> | null = null;
|
||||
for (const name of ['openclaw.json', 'clawdbot.json']) {
|
||||
const configPath = path.join(stateDir, name);
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
config = parseJson5(
|
||||
fs.readFileSync(configPath, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
break;
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: 'Could not load openclaw.json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channels =
|
||||
(config.channels as Record<string, unknown> | undefined) ?? {};
|
||||
const channelConfig =
|
||||
(channels[channel] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
// Try to resolve each credential field
|
||||
const results: Array<{
|
||||
envVar: string;
|
||||
resolved: string | null;
|
||||
masked: string;
|
||||
source: string;
|
||||
note?: string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < spec.fields.length; i++) {
|
||||
const field = spec.fields[i];
|
||||
const envVar = spec.envVars[i];
|
||||
|
||||
// Check top-level channel config first
|
||||
let rawValue = channelConfig[field];
|
||||
|
||||
// If not found, check first account
|
||||
if (!rawValue && channelConfig.accounts) {
|
||||
const accounts = channelConfig.accounts as Record<string, unknown>;
|
||||
const firstAccount = Object.values(accounts)[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (firstAccount) {
|
||||
rawValue = firstAccount[field];
|
||||
}
|
||||
}
|
||||
|
||||
const { resolved, source, note } = resolveSecretInput(
|
||||
rawValue,
|
||||
dotenvVars,
|
||||
);
|
||||
results.push({
|
||||
envVar,
|
||||
resolved,
|
||||
masked: resolved ? maskCredential(resolved) : '',
|
||||
source,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit results for the primary credential
|
||||
const primary = results[0];
|
||||
if (!primary) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: `No credential fields defined for ${channel}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If --write-env is set and credentials were resolved, write directly to .env.
|
||||
// Credential values never appear in stdout.
|
||||
let written = 0;
|
||||
if (writeEnv) {
|
||||
for (const r of results) {
|
||||
if (r.resolved) {
|
||||
writeEnvVar(writeEnv, r.envVar, r.resolved);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fields: Record<string, string | number | boolean> = {
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: !!primary.resolved,
|
||||
CREDENTIAL_SOURCE: primary.source,
|
||||
CREDENTIAL_MASKED: primary.masked || 'none',
|
||||
NANOCLAW_ENV_VAR: primary.envVar,
|
||||
};
|
||||
|
||||
if (writeEnv && written > 0) {
|
||||
fields.WRITTEN_TO = writeEnv;
|
||||
fields.WRITTEN_COUNT = written;
|
||||
}
|
||||
if (primary.note) {
|
||||
fields.NOTE = primary.note;
|
||||
}
|
||||
|
||||
// Additional credentials (e.g. Slack has botToken + appToken)
|
||||
if (results.length > 1) {
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const extra = results[i];
|
||||
const suffix = `_${i + 1}`;
|
||||
fields[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved;
|
||||
fields[`CREDENTIAL_SOURCE${suffix}`] = extra.source;
|
||||
fields[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none';
|
||||
fields[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar;
|
||||
if (extra.note) {
|
||||
fields[`NOTE${suffix}`] = extra.note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitStatus(fields);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,484 @@
|
||||
---
|
||||
name: migrate-nanoclaw
|
||||
description: Extracts user customizations from a fork, generates a replayable migration guide, and upgrades to upstream by reapplying customizations on a clean base. Replaces merge-based upgrades with intent-based migration.
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
NanoClaw users fork the repo and customize it — changing config values, editing source files, modifying personas, adding skills. When upstream ships updates or refactors, `git merge` produces painful conflicts because the same core files were changed on both sides.
|
||||
|
||||
This skill extracts the user's customizations into a migration guide — capturing both the intent (what they want) and the implementation details (how they did it, with code snippets, API calls, and specific configurations). On upgrade, it checks out clean upstream in a worktree, then reapplies customizations using the guide. No merge conflicts because there's nothing to merge.
|
||||
|
||||
The migration guide is markdown, not structured data. It needs to capture the full range of what a user might customize, with enough implementation detail that a fresh Claude session can reapply it without having seen the original code. Standard changes (config values, simple logic) can be described briefly. Non-standard changes (specific APIs, custom integrations, unusual patterns) need code snippets and precise instructions.
|
||||
|
||||
Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If a guide already exists, offer to skip to Upgrade.
|
||||
|
||||
# Principles
|
||||
|
||||
- Never proceed with a dirty working tree.
|
||||
- Always create a rollback point (backup branch + tag) before touching anything.
|
||||
- The migration guide is the source of truth, not diffs.
|
||||
- Use a worktree to validate before affecting the live install.
|
||||
- Data directories (`groups/`, `store/`, `data/`, `.env`) are never touched — only code.
|
||||
- Be helpful: offer to do things (stash, commit, stop services) rather than telling the user to do them.
|
||||
- **Use sub-agents for exploration.** Spawn haiku sub-agents to explore the codebase, trace skill merges, diff files, and identify customizations. This keeps the main context focused on the user conversation and decision-making.
|
||||
- **Always use absolute paths in worktrees.** The Bash tool resets the working directory between calls. Never use relative `cd .upgrade-worktree` — always use the full absolute path: `cd /absolute/path/.upgrade-worktree && <command>`. Store the worktree absolute path in a variable at creation time and reference it throughout.
|
||||
- **Balance exploration and asking.** Don't bombard the user with questions when you can figure things out from the code. Don't burn endless tokens exploring when the user could clarify in one sentence. Use sub-agents to explore first, then ask the user targeted questions about things that are ambiguous or where intent isn't clear from the code alone.
|
||||
- **Scale effort to complexity.** Not every migration needs the full process. Assess the scope early and take the lightest path that fits.
|
||||
|
||||
---
|
||||
|
||||
# Phase 1: Extract
|
||||
|
||||
## 1.0 Preflight
|
||||
|
||||
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
|
||||
|
||||
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
|
||||
|
||||
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
|
||||
|
||||
## 1.1 Assess scope and determine path
|
||||
|
||||
Quickly assess the scale of divergence, check for an existing guide, and determine the right approach — all before asking the user anything.
|
||||
|
||||
```bash
|
||||
BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)
|
||||
# Divergence stats
|
||||
git rev-list --count $BASE..upstream/$UPSTREAM_BRANCH # upstream commits
|
||||
git rev-list --count $BASE..HEAD # user commits
|
||||
git diff --name-only $BASE..HEAD | wc -l # user changed files
|
||||
git diff --stat $BASE..HEAD | tail -1 # insertions/deletions
|
||||
git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH | wc -l # upstream changed files
|
||||
```
|
||||
|
||||
Check for existing guide: `.nanoclaw-migrations/guide.md` or `.nanoclaw-migrations/index.md`.
|
||||
|
||||
**Determine the tier based on the total diff from base:**
|
||||
|
||||
### Tier 1: Lightweight — suggest `/update-nanoclaw` instead
|
||||
|
||||
Conditions (any of):
|
||||
- Very few upstream changes (< ~5 commits) AND few user changes (< ~3 changed files)
|
||||
- User recently updated/migrated (merge-base is close to upstream HEAD)
|
||||
|
||||
Tell the user the scope is small and suggest `/update-nanoclaw` might be simpler. Let them choose.
|
||||
|
||||
### Tier 2: Standard
|
||||
|
||||
Conditions:
|
||||
- Moderate total diff (3-15 changed files, no large number of new files)
|
||||
- Manageable scope that fits in a single guide file
|
||||
|
||||
### Tier 3: Complex
|
||||
|
||||
Conditions (any of):
|
||||
- Many new files added (indicates many skills applied) — discount files that come purely from skill merges when assessing complexity; a fork with 3 skills and no other changes is simpler than it looks by file count alone
|
||||
- Deep source changes to core files (`src/index.ts`, `src/container-runner.ts`, etc.) beyond what skills introduced
|
||||
- Lots of insertions/deletions in user-authored code (not skill-merged code)
|
||||
- Many skills applied (3+) AND the user confirms or sub-agents find customizations on top of them
|
||||
|
||||
Use the full process: multiple sub-agents in parallel, directory-based guide, migration plan.
|
||||
|
||||
**Now combine the scope assessment with initial user input in one interaction.** Present the scope summary (how many commits, files, which tier) and ask (AskUserQuestion):
|
||||
|
||||
For Tier 1:
|
||||
- **Use /update-nanoclaw** — simpler merge-based approach
|
||||
- **Proceed with full migration** — continue
|
||||
|
||||
For Tier 2/3 (with or without existing guide):
|
||||
- If guide exists and is current: **Skip to upgrade** / **Update guide** (add new changes) / **Re-extract from scratch**
|
||||
- If guide exists but is stale: **Update guide** (recommended) / **Re-extract from scratch** / **Skip to upgrade anyway**
|
||||
- If no guide: **Yes, let me describe my customizations first** / **Just figure it out** / **A bit of both**
|
||||
|
||||
This single interaction replaces what were previously separate steps for scope assessment, user input, and existing guide check.
|
||||
|
||||
## 1.2 Update existing guide (if applicable)
|
||||
|
||||
If the user chose to update an existing guide rather than re-extract:
|
||||
|
||||
1. Read the existing guide
|
||||
2. Find commits made since the guide was generated (compare guide's recorded base hash against current HEAD)
|
||||
3. Spawn a haiku sub-agent to analyze only the new changes:
|
||||
> Diff HEAD against `<guide-recorded-hash>`. For each changed file, summarize what changed and why.
|
||||
4. Present the new changes to the user for confirmation
|
||||
5. Append new customizations to the existing guide, update the header hashes
|
||||
6. Skip to Phase 2
|
||||
|
||||
## 1.3 Explore the codebase
|
||||
|
||||
Spawn a haiku sub-agent (Agent tool, model: haiku) for initial exploration:
|
||||
|
||||
> Explore this NanoClaw fork to identify all changes from the upstream base. Run these commands and report back:
|
||||
>
|
||||
> 1. `git diff --name-only $BASE..HEAD` — all changed files
|
||||
> 2. `git log --oneline $BASE..HEAD` — all commits (look for skill branch merges like `Merge branch 'skill/*'`)
|
||||
> 3. `git branch -r --list 'upstream/skill/*'` — available upstream skill branches
|
||||
> 4. `ls .claude/skills/` — installed skills
|
||||
> 5. For each skill merge found, record the merge commit hash
|
||||
>
|
||||
> Report: (a) list of applied skills with their merge commit hashes, (b) list of all changed files, (c) any custom skill directories that don't match upstream branches.
|
||||
|
||||
From the sub-agent results, identify:
|
||||
- **Which files came purely from skill merges** — these will be reapplied by re-merging skill branches in Phase 2
|
||||
- **Everything else** — all remaining changes are customizations to analyze (whether they're on skill-touched files or not)
|
||||
|
||||
Don't try to distinguish "user modified a skill file" from "user made their own change" at this stage. The sub-agents in 1.4 will look at all non-skill changes together and surface what matters.
|
||||
|
||||
## 1.4 Analyze customizations
|
||||
|
||||
For each applied skill, ask the user in a single batched question (AskUserQuestion, multiSelect):
|
||||
|
||||
> "I found these applied skills. Select any you customized further after applying:"
|
||||
|
||||
Options: one per skill, plus "None — all used as-is".
|
||||
|
||||
Then spawn sub-agents to analyze all non-skill changes. For Tier 2, one or two agents. For Tier 3, run in parallel by area:
|
||||
|
||||
- **Config + build files** — one sub-agent
|
||||
- **Source files** (`src/*.ts`) — one sub-agent
|
||||
- **Skills the user flagged as modified** (or all of them for Tier 3) — one sub-agent per skill, comparing the user's current files against the skill merge commit version:
|
||||
```
|
||||
git diff <merge-commit-hash>..HEAD -- <files-touched-by-skill>
|
||||
```
|
||||
- **Container files** — one sub-agent (if changes exist)
|
||||
|
||||
Each sub-agent task:
|
||||
|
||||
> Read these diffs and the current file contents. For each change:
|
||||
> 1. `git diff $BASE..HEAD -- <file>` (or `git diff <skill-merge-hash>..HEAD -- <file>` for skill-modified files)
|
||||
> 2. Read the full current file for context
|
||||
> 3. Summarize: what changed, what the likely intent is
|
||||
> 4. Assess detail level: could a fresh Claude session reproduce this from intent alone, or does it need specific code snippets, API details, import paths?
|
||||
> 5. For non-standard changes, extract the key code, imports, API calls, and configurations verbatim.
|
||||
|
||||
**Inter-skill conflicts:** If multiple skills are applied, spawn an additional sub-agent to check for interactions between them. Look for:
|
||||
- Duplicate declarations (same variable/constant defined by two skill branches)
|
||||
- Conflicting approaches (one skill throws on missing env var, another provides a fallback)
|
||||
- Shared files modified by multiple skills
|
||||
|
||||
Document any findings in the "Skill Interactions" section of the migration guide so they can be resolved after skill branches are re-merged during upgrade.
|
||||
|
||||
## 1.5 Confirm with user
|
||||
|
||||
After sub-agents report back, compile the findings and present to the user.
|
||||
|
||||
For customizations where the intent is clear (config values, simple modifications): present as a batch for confirmation. Use AskUserQuestion with multiSelect to let the user flag any entries that need correction.
|
||||
|
||||
For customizations where the intent is ambiguous: ask specific questions. Don't ask "what did you do?" — instead ask "I see you added X in this file. Was this for Y or something else?"
|
||||
|
||||
The user can select "Other" on any question to provide their own description.
|
||||
|
||||
## 1.6 Migration plan (Tier 3 only)
|
||||
|
||||
For complex migrations, before writing the guide, create a migration plan:
|
||||
|
||||
- **Order of operations**: which customizations depend on others, which skills must be applied first
|
||||
- **Staging**: whether the migration should happen in stages (e.g. apply skills first, validate, then apply source customizations)
|
||||
- **Risk areas**: customizations that touch files heavily changed by upstream — these may need manual review
|
||||
- **Interactions**: customizations that interact with each other (e.g. a source change that depends on a skill, or two customizations that touch the same file)
|
||||
|
||||
Present the plan to the user for review before proceeding to the guide.
|
||||
|
||||
## 1.7 Write the migration guide
|
||||
|
||||
**Storage:** `.nanoclaw-migrations/guide.md` for Tier 2. `.nanoclaw-migrations/` directory with `index.md` and section files for Tier 3.
|
||||
|
||||
**Verification:** After writing the guide, read it back and verify:
|
||||
- Every referenced file path exists in the current codebase
|
||||
- Code snippets match what's actually in the files
|
||||
- No customizations from the analysis were accidentally omitted
|
||||
|
||||
The guide is structured markdown that a fresh Claude session can follow to reproduce this user's exact setup on a clean upstream checkout.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# NanoClaw Migration Guide
|
||||
|
||||
Generated: <timestamp>
|
||||
Base: <BASE hash>
|
||||
HEAD at generation: <HEAD hash>
|
||||
Upstream: <upstream HEAD hash>
|
||||
|
||||
## Migration Plan
|
||||
|
||||
(Tier 3 only — big-picture overview of order, staging, risks)
|
||||
|
||||
## Applied Skills
|
||||
|
||||
List each skill with its branch name. These are reapplied by merging the upstream skill branch.
|
||||
|
||||
- `add-telegram` — branch `skill/telegram`
|
||||
- `add-voice-transcription` — branch `skill/voice-transcription`
|
||||
|
||||
Custom skills (user-created, not from upstream): `.claude/skills/my-custom-skill/` — copy as-is from main tree.
|
||||
|
||||
## Skill Interactions
|
||||
|
||||
(Document known conflicts or interactions between applied skills.
|
||||
When two or more skills modify the same file or depend on shared
|
||||
config, describe the conflict and how to resolve it after merging.
|
||||
Example: skill A and skill B both add a PROXY_BIND_HOST declaration —
|
||||
after merging both, deduplicate. Or: skill A throws if ENV_VAR is
|
||||
missing, but skill B provides a fallback — use the fallback version.)
|
||||
|
||||
## Modifications to Applied Skills
|
||||
|
||||
### <Skill name>: <what was modified>
|
||||
|
||||
**Intent:** ...
|
||||
|
||||
**Files:** ...
|
||||
|
||||
**How to apply:** (after the skill branch has been merged)
|
||||
|
||||
...
|
||||
|
||||
## Customizations
|
||||
|
||||
### <Descriptive title for customization>
|
||||
|
||||
**Intent:** What the user wants and why.
|
||||
|
||||
**Files:** Which files to modify.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
<For standard changes, a brief description is enough.>
|
||||
|
||||
<For non-standard changes, include code snippets, API details,
|
||||
specific values, import paths — everything needed to reproduce
|
||||
without seeing the original diff.>
|
||||
|
||||
### <Next customization...>
|
||||
```
|
||||
|
||||
**Judging detail level:** For each customization, assess whether a fresh Claude session could reproduce it from intent alone:
|
||||
- **Standard changes** (config values, simple logic, well-known patterns): describe the intent and the target. Example: "Change `POLL_INTERVAL` in `src/config.ts` from 2000 to 1000."
|
||||
- **Non-standard changes** (specific API usage, custom integrations, unusual patterns, library-specific configurations): include the actual code snippets, import paths, API endpoints, configuration objects — everything needed to reproduce it without guessing.
|
||||
|
||||
Example entries at different detail levels:
|
||||
|
||||
**Standard (brief):**
|
||||
```markdown
|
||||
### Custom trigger word
|
||||
|
||||
**Intent:** Use `@Bob` instead of the default `@Andy`.
|
||||
|
||||
**Files:** `src/config.ts`
|
||||
|
||||
**How to apply:** Change the default value of `ASSISTANT_NAME` from `'Andy'` to `'Bob'`.
|
||||
```
|
||||
|
||||
**Non-standard (detailed):**
|
||||
```markdown
|
||||
### Spanish translation for outbound messages
|
||||
|
||||
**Intent:** All outbound messages are translated to Spanish before sending. Uses the DeepL API via the `deepl-node` package.
|
||||
|
||||
**Files:** `src/router.ts`, `package.json`
|
||||
|
||||
**How to apply:**
|
||||
|
||||
1. Add dependency: `npm install deepl-node`
|
||||
|
||||
2. In `src/router.ts`, add import at top:
|
||||
```typescript
|
||||
import * as deepl from 'deepl-node';
|
||||
const translator = new deepl.Translator(process.env.DEEPL_API_KEY!);
|
||||
```
|
||||
|
||||
3. In the `formatOutbound` function, before the return statement, add:
|
||||
```typescript
|
||||
const result = await translator.translateText(text, null, 'es');
|
||||
text = result.text;
|
||||
```
|
||||
Note: the function needs to be made async if it isn't already.
|
||||
```
|
||||
|
||||
After writing, offer to commit for the user:
|
||||
```bash
|
||||
git add .nanoclaw-migrations/
|
||||
git commit -m "chore: save migration guide"
|
||||
```
|
||||
|
||||
Ask (AskUserQuestion): "Migration guide saved. Want to upgrade now or later?"
|
||||
- **Upgrade now** — continue to Phase 2
|
||||
- **Later** — stop here
|
||||
|
||||
---
|
||||
|
||||
# Phase 2: Upgrade
|
||||
|
||||
## 2.0 Preflight
|
||||
|
||||
Same checks as 1.0 — clean tree (offer to stash/commit if dirty), upstream configured, fetch latest.
|
||||
|
||||
Read the migration guide. If missing, tell the user you need to extract customizations first and ask if they want to do that now.
|
||||
|
||||
**New-changes guard:** Compare the guide's "HEAD at generation" hash against current HEAD. If there are commits since the guide was generated, warn the user:
|
||||
|
||||
> "You've made changes since the migration guide was generated. These changes won't be included in the upgrade."
|
||||
|
||||
AskUserQuestion:
|
||||
- **Update the guide first** — go to step 1.2 to incorporate new changes
|
||||
- **Proceed anyway** — user accepts that recent changes will be lost
|
||||
- **Abort** — stop
|
||||
|
||||
## 2.1 Safety net
|
||||
|
||||
```bash
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
git branch backup/pre-migrate-$HASH-$TIMESTAMP
|
||||
git tag pre-migrate-$HASH-$TIMESTAMP
|
||||
```
|
||||
|
||||
Save the tag name for rollback instructions at the end.
|
||||
|
||||
## 2.2 Preview upstream changes
|
||||
|
||||
```bash
|
||||
BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)
|
||||
git log --oneline $BASE..upstream/$UPSTREAM_BRANCH
|
||||
git diff $BASE..upstream/$UPSTREAM_BRANCH -- CHANGELOG.md
|
||||
```
|
||||
|
||||
If there are `[BREAKING]` entries, show them and explain how they interact with the user's customizations from the migration guide.
|
||||
|
||||
Ask (AskUserQuestion) to proceed or abort.
|
||||
|
||||
## 2.3 Create upgrade worktree
|
||||
|
||||
```bash
|
||||
PROJECT_ROOT=$(pwd)
|
||||
git worktree add .upgrade-worktree upstream/$UPSTREAM_BRANCH --detach
|
||||
WORKTREE="$PROJECT_ROOT/.upgrade-worktree"
|
||||
```
|
||||
|
||||
Store `$PROJECT_ROOT` and `$WORKTREE` as absolute paths. Use `$WORKTREE` in all subsequent commands — never `cd .upgrade-worktree` with a relative path.
|
||||
|
||||
## 2.4 Reapply skills in worktree
|
||||
|
||||
For each skill listed in the migration guide's "Applied Skills" section:
|
||||
|
||||
1. Check if branch exists: `git branch -r --list "upstream/$branch"`
|
||||
2. If yes, merge it in the worktree:
|
||||
```bash
|
||||
cd "$WORKTREE" && git merge upstream/skill/<name> --no-edit
|
||||
```
|
||||
3. If missing, warn the user (skill may have been removed or renamed upstream).
|
||||
4. If any skill merge conflicts, stop and tell the user — the skill needs updating for the new upstream.
|
||||
|
||||
Copy any custom skills mentioned in the guide from the main tree into the worktree.
|
||||
|
||||
## 2.5 Reapply customizations in worktree
|
||||
|
||||
Work in `.upgrade-worktree/`. Follow each customization section in the migration guide, including "Modifications to Applied Skills."
|
||||
|
||||
For Tier 3 migrations with a migration plan, follow the plan's ordering and staging. If the plan calls for staged validation (e.g. validate after skills, then validate after source changes), do so.
|
||||
|
||||
For each customization:
|
||||
1. Read the "How to apply" instructions from the guide
|
||||
2. Read the target file(s) in the worktree to understand the current upstream version
|
||||
3. Apply the changes as described — use the code snippets and specific instructions from the guide
|
||||
4. If the target file has changed significantly from what the guide expects (function removed, file restructured, API changed), flag it and ask the user what to do
|
||||
5. Verify the file has no syntax errors or broken imports after each change
|
||||
|
||||
For behavior customizations (CLAUDE.md files): copy from the main tree. These are user content, not code.
|
||||
|
||||
## 2.6 Validate in worktree
|
||||
|
||||
```bash
|
||||
cd "$WORKTREE" && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user.
|
||||
|
||||
## 2.7 Live test (optional)
|
||||
|
||||
Ask (AskUserQuestion):
|
||||
- **Test live** — stop service, run from worktree against real data, send a test message
|
||||
- **Skip** — trust the build, proceed to swap
|
||||
|
||||
If testing live:
|
||||
|
||||
1. Stop the service (do this directly):
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true
|
||||
```
|
||||
|
||||
2. Symlink data into the worktree:
|
||||
```bash
|
||||
ln -s "$PROJECT_ROOT/store" "$WORKTREE/store"
|
||||
ln -s "$PROJECT_ROOT/data" "$WORKTREE/data"
|
||||
ln -s "$PROJECT_ROOT/groups" "$WORKTREE/groups"
|
||||
ln -s "$PROJECT_ROOT/.env" "$WORKTREE/.env"
|
||||
```
|
||||
|
||||
3. Start from worktree: `cd "$WORKTREE" && npm run dev`
|
||||
|
||||
4. Ask the user to send a test message from their phone. Wait for them to confirm it works.
|
||||
|
||||
5. After confirmation, stop the dev server.
|
||||
|
||||
6. Clean up symlinks:
|
||||
```bash
|
||||
rm "$WORKTREE/store" "$WORKTREE/data" "$WORKTREE/groups" "$WORKTREE/.env"
|
||||
```
|
||||
|
||||
## 2.8 Swap into main tree
|
||||
|
||||
The swap must be done carefully — the worktree has the upgraded code, but main needs to point to it cleanly. Use absolute paths throughout.
|
||||
|
||||
```bash
|
||||
# 1. Capture the worktree HEAD before removing it
|
||||
WORKTREE_PATH=$(cd "$PROJECT_ROOT/.upgrade-worktree" && pwd)
|
||||
UPGRADE_COMMIT=$(git -C "$WORKTREE_PATH" rev-parse HEAD)
|
||||
|
||||
# 2. Copy the migration guide out of the worktree before removing it
|
||||
cp -r "$WORKTREE_PATH/.nanoclaw-migrations" /tmp/nanoclaw-migrations-backup 2>/dev/null || true
|
||||
|
||||
# 3. Remove the worktree
|
||||
git worktree remove "$WORKTREE_PATH" --force
|
||||
|
||||
# 4. Point the current branch at the upgraded commit
|
||||
git reset --hard $UPGRADE_COMMIT
|
||||
|
||||
# 5. Restore the migration guide and update its hashes
|
||||
cp -r /tmp/nanoclaw-migrations-backup/* .nanoclaw-migrations/ 2>/dev/null || true
|
||||
rm -rf /tmp/nanoclaw-migrations-backup
|
||||
```
|
||||
|
||||
Update the guide's header hashes to reflect the new state. Offer to commit:
|
||||
```bash
|
||||
git add .nanoclaw-migrations/
|
||||
git commit -m "chore: upgrade to upstream $(git rev-parse --short upstream/$UPSTREAM_BRANCH)"
|
||||
```
|
||||
|
||||
Do NOT use `git checkout -B` to create an intermediate branch — this caused issues in practice. The `git reset --hard` to the upgrade commit is the cleanest path since the backup tag already preserves the pre-upgrade state.
|
||||
|
||||
## 2.9 Post-upgrade
|
||||
|
||||
Run `npm install && npm run build` in the main tree to confirm.
|
||||
|
||||
Restart the service:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
```
|
||||
|
||||
Show summary:
|
||||
- Previous version (backup tag)
|
||||
- New HEAD
|
||||
- Customizations reapplied (list from guide)
|
||||
- Skills reapplied
|
||||
- Rollback: `git reset --hard <backup-tag>`
|
||||
- Any customizations that needed manual adjustment
|
||||
|
||||
Offer to pop the stash if one was created in preflight: `git stash pop`
|
||||
|
||||
## Diagnostics
|
||||
|
||||
1. Use the Read tool to read `.claude/skills/migrate-nanoclaw/diagnostics.md`.
|
||||
2. Follow every step in that file before finishing.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Diagnostics
|
||||
|
||||
Gather system info:
|
||||
|
||||
```bash
|
||||
node -p "require('./package.json').version"
|
||||
uname -s
|
||||
uname -m
|
||||
node -p "process.versions.node.split('.')[0]"
|
||||
```
|
||||
|
||||
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
|
||||
"event": "migrate_complete",
|
||||
"distinct_id": "<uuid>",
|
||||
"properties": {
|
||||
"success": true,
|
||||
"nanoclaw_version": "1.2.43",
|
||||
"os_platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"migration_phase": "extract|upgrade|both",
|
||||
"tier": 2,
|
||||
"customization_count": 3,
|
||||
"skills_applied_count": 2,
|
||||
"skill_interaction_count": 0,
|
||||
"live_test": false,
|
||||
"breaking_changes_found": false,
|
||||
"error_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
|
||||
|
||||
**Yes**:
|
||||
```bash
|
||||
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
|
||||
rm /tmp/nanoclaw-diagnostics.json
|
||||
```
|
||||
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
4. Remove the diagnostics sections from each corresponding SKILL.md
|
||||
5. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
@@ -70,6 +70,22 @@ 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
|
||||
|
||||
### OpenClaw Migration Detection
|
||||
|
||||
Check for an existing OpenClaw installation:
|
||||
|
||||
```bash
|
||||
ls -d ~/.openclaw 2>/dev/null || ls -d ~/.clawdbot 2>/dev/null
|
||||
```
|
||||
|
||||
If a directory is found, AskUserQuestion:
|
||||
|
||||
1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup."
|
||||
2. **Fresh start** — "Skip migration and set up NanoClaw from scratch."
|
||||
3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later."
|
||||
|
||||
If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone).
|
||||
|
||||
## 2a. Timezone
|
||||
|
||||
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
|
||||
@@ -146,14 +162,14 @@ grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin
|
||||
|
||||
Then re-verify with `onecli version`.
|
||||
|
||||
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
|
||||
Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above:
|
||||
```bash
|
||||
onecli config set api-host http://127.0.0.1:10254
|
||||
onecli config set api-host ${ONECLI_URL}
|
||||
```
|
||||
|
||||
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
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env
|
||||
```
|
||||
|
||||
Check if a secret already exists:
|
||||
@@ -178,7 +194,7 @@ Then stop and wait for the user to confirm they have the token. Do NOT proceed u
|
||||
|
||||
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."
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} 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
|
||||
@@ -187,7 +203,7 @@ Tell the user to get an API key from https://console.anthropic.com/settings/keys
|
||||
|
||||
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."
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} 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
|
||||
@@ -200,7 +216,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=<their-token>' >> .env
|
||||
```
|
||||
|
||||
For API key: add to `.env`:
|
||||
```bash
|
||||
echo 'ANTHROPIC_API_KEY=<their-key>' >> .env
|
||||
```
|
||||
|
||||
Verify the proxy starts: `npm run dev` should show "Credential proxy listening" in the logs.
|
||||
|
||||
## 5. Set Up Channels
|
||||
|
||||
@@ -287,7 +324,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), 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).
|
||||
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl ${ONECLI_URL}/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`.
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ uname -m
|
||||
node -p "process.versions.node.split('.')[0]"
|
||||
```
|
||||
|
||||
Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration.
|
||||
|
||||
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
|
||||
|
||||
```json
|
||||
@@ -23,6 +25,7 @@ Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP ad
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"channels_selected": ["telegram", "whatsapp"],
|
||||
"migrated_from_openclaw": false,
|
||||
"error_count": 0,
|
||||
"failed_step": null
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ Bucket the upstream changed files:
|
||||
- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
|
||||
- **Other**: docs, tests, misc
|
||||
|
||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||
|
||||
Present these buckets to the user and ask them to choose one path using AskUserQuestion:
|
||||
- A) **Full update**: merge all upstream changes
|
||||
- B) **Selective update**: cherry-pick specific upstream commits
|
||||
@@ -188,7 +190,7 @@ After validation succeeds, check if the update introduced any breaking changes.
|
||||
Determine which CHANGELOG entries are new by diffing against the backup tag:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
|
||||
|
||||
Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is:
|
||||
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is:
|
||||
```
|
||||
[BREAKING] <description>. Run `/<skill-name>` to <action>.
|
||||
```
|
||||
|
||||
+5
-1
@@ -4,9 +4,13 @@ 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.36] - 2026-03-26
|
||||
|
||||
- [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`.
|
||||
|
||||
## [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.
|
||||
- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Check your runtime: `grep CONTAINER_RUNTIME_BIN src/container-runtime.ts` — if it shows `'container'` you are on Apple Container, if `'docker'` you are on Docker. Docker users: run `/init-onecli` to install OneCLI and migrate `.env` credentials to the vault. Apple Container users: re-merge the skill branch (`git fetch upstream skill/apple-container && git merge upstream/skill/apple-container`) then run `/convert-to-apple-container` and follow all instructions (configures credential proxy networking) — do NOT run `/init-onecli`, it requires Docker.
|
||||
|
||||
## [1.2.21] - 2026-03-22
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
community@nanoclaw.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -16,3 +16,11 @@ Thanks to everyone who has contributed to NanoClaw!
|
||||
- [flobo3](https://github.com/flobo3) — Flo
|
||||
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
|
||||
- [scottgl9](https://github.com/scottgl9) — Scott Glover
|
||||
- [cschmidt](https://github.com/cschmidt) — Carl Schmidt
|
||||
- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler
|
||||
- [moktamd](https://github.com/moktamd)
|
||||
- [gurixs-carson](https://github.com/gurixs-carson)
|
||||
- [MrBlaise](https://github.com/MrBlaise) — Balázs Rostás
|
||||
- [lbsnrs](https://github.com/lbsnrs) — Andreas Liebschner
|
||||
- [spencer-whitman](https://github.com/spencer-whitman)
|
||||
- [lazure-ocean](https://github.com/lazure-ocean) — Cyril Ionov
|
||||
|
||||
@@ -30,8 +30,8 @@ RUN apt-get update && apt-get install -y \
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Install agent-browser and claude-code globally
|
||||
RUN npm install -g agent-browser @anthropic-ai/claude-code
|
||||
# Install agent-browser, claude-code, and QMD globally
|
||||
RUN npm install -g agent-browser @anthropic-ai/claude-code @tobilu/qmd
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
Generated
+59
-7
@@ -8,7 +8,7 @@
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
@@ -19,10 +19,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"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==",
|
||||
"version": "0.2.92",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.92.tgz",
|
||||
"integrity": "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -41,6 +45,35 @@
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz",
|
||||
"integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
@@ -358,9 +391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
@@ -1013,6 +1046,19 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -1428,6 +1474,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import {
|
||||
query,
|
||||
HookCallback,
|
||||
PreCompactHookInput,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
@@ -90,7 +94,9 @@ class MessageStream {
|
||||
yield this.queue.shift()!;
|
||||
}
|
||||
if (this.done) return;
|
||||
await new Promise<void>(r => { this.waiting = r; });
|
||||
await new Promise<void>((r) => {
|
||||
this.waiting = r;
|
||||
});
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +106,9 @@ async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { data += chunk; });
|
||||
process.stdin.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
@@ -119,7 +127,10 @@ function log(message: string): void {
|
||||
console.error(`[agent-runner] ${message}`);
|
||||
}
|
||||
|
||||
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
|
||||
function getSessionSummary(
|
||||
sessionId: string,
|
||||
transcriptPath: string,
|
||||
): string | null {
|
||||
const projectDir = path.dirname(transcriptPath);
|
||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||
|
||||
@@ -129,13 +140,17 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
|
||||
}
|
||||
|
||||
try {
|
||||
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
const entry = index.entries.find(e => e.sessionId === sessionId);
|
||||
const index: SessionsIndex = JSON.parse(
|
||||
fs.readFileSync(indexPath, 'utf-8'),
|
||||
);
|
||||
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
||||
if (entry?.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
|
||||
log(
|
||||
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -174,12 +189,18 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
const filename = `${date}-${name}.md`;
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
||||
const markdown = formatTranscriptMarkdown(
|
||||
messages,
|
||||
summary,
|
||||
assistantName,
|
||||
);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
log(
|
||||
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
@@ -212,9 +233,12 @@ function parseTranscript(content: string): ParsedMessage[] {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text = typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content
|
||||
.map((c: { text?: string }) => c.text || '')
|
||||
.join('');
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const textParts = entry.message.content
|
||||
@@ -223,22 +247,26 @@ function parseTranscript(content: string): ParsedMessage[] {
|
||||
const text = textParts.join('');
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
||||
function formatTranscriptMarkdown(
|
||||
messages: ParsedMessage[],
|
||||
title?: string | null,
|
||||
assistantName?: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
const formatDateTime = (d: Date) =>
|
||||
d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title || 'Conversation'}`);
|
||||
@@ -249,10 +277,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
|
||||
const content = msg.content.length > 2000
|
||||
? msg.content.slice(0, 2000) + '...'
|
||||
: msg.content;
|
||||
const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
|
||||
const content =
|
||||
msg.content.length > 2000
|
||||
? msg.content.slice(0, 2000) + '...'
|
||||
: msg.content;
|
||||
lines.push(`**${sender}**: ${content}`);
|
||||
lines.push('');
|
||||
}
|
||||
@@ -265,7 +294,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
|
||||
*/
|
||||
function shouldClose(): boolean {
|
||||
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
||||
try {
|
||||
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -278,8 +311,9 @@ function shouldClose(): boolean {
|
||||
function drainIpcInput(): string[] {
|
||||
try {
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
const files = fs.readdirSync(IPC_INPUT_DIR)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
const files = fs
|
||||
.readdirSync(IPC_INPUT_DIR)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
const messages: string[] = [];
|
||||
@@ -292,8 +326,14 @@ function drainIpcInput(): string[] {
|
||||
messages.push(data.text);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
log(
|
||||
`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
@@ -338,7 +378,11 @@ async function runQuery(
|
||||
containerInput: ContainerInput,
|
||||
sdkEnv: Record<string, string | undefined>,
|
||||
resumeAt?: string,
|
||||
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
|
||||
): Promise<{
|
||||
newSessionId?: string;
|
||||
lastAssistantUuid?: string;
|
||||
closedDuringQuery: boolean;
|
||||
}> {
|
||||
const stream = new MessageStream();
|
||||
stream.push(prompt);
|
||||
|
||||
@@ -399,17 +443,33 @@ async function runQuery(
|
||||
resume: sessionId,
|
||||
resumeSessionAt: resumeAt,
|
||||
systemPrompt: globalClaudeMd
|
||||
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
|
||||
? {
|
||||
type: 'preset' as const,
|
||||
preset: 'claude_code' as const,
|
||||
append: globalClaudeMd,
|
||||
}
|
||||
: undefined,
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch',
|
||||
'Task', 'TaskOutput', 'TaskStop',
|
||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||
'TodoWrite', 'ToolSearch', 'Skill',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'Task',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TeamCreate',
|
||||
'TeamDelete',
|
||||
'SendMessage',
|
||||
'TodoWrite',
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*'
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__qmd__*',
|
||||
],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
@@ -425,14 +485,23 @@ async function runQuery(
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
qmd: {
|
||||
type: 'http',
|
||||
url: 'http://host.docker.internal:8182/mcp',
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreCompact: [
|
||||
{ hooks: [createPreCompactHook(containerInput.assistantName)] },
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
})) {
|
||||
messageCount++;
|
||||
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
|
||||
const msgType =
|
||||
message.type === 'system'
|
||||
? `system/${(message as { subtype?: string }).subtype}`
|
||||
: message.type;
|
||||
log(`[msg #${messageCount}] type=${msgType}`);
|
||||
|
||||
if (message.type === 'assistant' && 'uuid' in message) {
|
||||
@@ -444,25 +513,39 @@ async function runQuery(
|
||||
log(`Session initialized: ${newSessionId}`);
|
||||
}
|
||||
|
||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { task_id: string; status: string; summary: string };
|
||||
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
|
||||
if (
|
||||
message.type === 'system' &&
|
||||
(message as { subtype?: string }).subtype === 'task_notification'
|
||||
) {
|
||||
const tn = message as {
|
||||
task_id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
};
|
||||
log(
|
||||
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
||||
const textResult =
|
||||
'result' in message ? (message as { result?: string }).result : null;
|
||||
log(
|
||||
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
|
||||
);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
newSessionId
|
||||
newSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcPolling = false;
|
||||
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
|
||||
log(
|
||||
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`,
|
||||
);
|
||||
return { newSessionId, lastAssistantUuid, closedDuringQuery };
|
||||
}
|
||||
|
||||
@@ -478,40 +561,47 @@ async function runScript(script: string): Promise<ScriptResult | null> {
|
||||
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)}`);
|
||||
}
|
||||
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)}`);
|
||||
if (error) {
|
||||
log(`Script error: ${error.message}`);
|
||||
return resolve(null);
|
||||
}
|
||||
resolve(result as ScriptResult);
|
||||
} catch {
|
||||
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -521,20 +611,27 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||
try {
|
||||
fs.unlinkSync('/tmp/input.json');
|
||||
} catch {
|
||||
/* may not exist */
|
||||
}
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
|
||||
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||
// No real secrets exist in the container environment.
|
||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||
const sdkEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
@@ -543,7 +640,11 @@ async function main(): Promise<void> {
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
|
||||
// Clean up stale _close sentinel from previous container runs
|
||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
||||
try {
|
||||
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Build initial prompt (drain any pending IPC messages too)
|
||||
let prompt = containerInput.prompt;
|
||||
@@ -562,7 +663,9 @@ async function main(): Promise<void> {
|
||||
const scriptResult = await runScript(containerInput.script);
|
||||
|
||||
if (!scriptResult || !scriptResult.wakeAgent) {
|
||||
const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output';
|
||||
const reason = scriptResult
|
||||
? 'wakeAgent=false'
|
||||
: 'script error/no output';
|
||||
log(`Script decided not to wake agent: ${reason}`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
@@ -580,9 +683,18 @@ async function main(): Promise<void> {
|
||||
let resumeAt: string | undefined;
|
||||
try {
|
||||
while (true) {
|
||||
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
|
||||
log(
|
||||
`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`,
|
||||
);
|
||||
|
||||
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
|
||||
const queryResult = await runQuery(
|
||||
prompt,
|
||||
sessionId,
|
||||
mcpServerPath,
|
||||
containerInput,
|
||||
sdkEnv,
|
||||
resumeAt,
|
||||
);
|
||||
if (queryResult.newSessionId) {
|
||||
sessionId = queryResult.newSessionId;
|
||||
}
|
||||
@@ -620,7 +732,7 @@ async function main(): Promise<void> {
|
||||
status: 'error',
|
||||
result: null,
|
||||
newSessionId: sessionId,
|
||||
error: errorMessage
|
||||
error: errorMessage,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ server.tool(
|
||||
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
|
||||
{
|
||||
text: z.string().describe('The message text to send'),
|
||||
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
|
||||
sender: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
const data: Record<string, string | undefined> = {
|
||||
@@ -86,12 +91,39 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
|
||||
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
|
||||
{
|
||||
prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'),
|
||||
schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'),
|
||||
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.'),
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'What the agent should do when the task runs. For isolated mode, include all necessary context here.',
|
||||
),
|
||||
schedule_type: z
|
||||
.enum(['cron', 'interval', 'once'])
|
||||
.describe(
|
||||
'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time',
|
||||
),
|
||||
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
|
||||
@@ -100,7 +132,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
CronExpressionParser.parse(args.schedule_value);
|
||||
} catch {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -108,28 +145,47 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
const ms = parseInt(args.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else if (args.schedule_type === 'once') {
|
||||
if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) {
|
||||
if (
|
||||
/[Zz]$/.test(args.schedule_value) ||
|
||||
/[+-]\d{2}:\d{2}$/.test(args.schedule_value)
|
||||
) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const date = new Date(args.schedule_value);
|
||||
if (isNaN(date.getTime())) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Non-main groups can only schedule for themselves
|
||||
const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||
const targetJid =
|
||||
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||
|
||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
@@ -149,7 +205,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -163,30 +224,56 @@ server.tool(
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tasksFile)) {
|
||||
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
||||
|
||||
const tasks = isMain
|
||||
? allTasks
|
||||
: allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder);
|
||||
: allTasks.filter(
|
||||
(t: { groupFolder: string }) => t.groupFolder === groupFolder,
|
||||
);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = tasks
|
||||
.map(
|
||||
(t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) =>
|
||||
(t: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string;
|
||||
}) =>
|
||||
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] };
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -207,7 +294,14 @@ server.tool(
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} pause requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -226,7 +320,14 @@ server.tool(
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} resume requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -245,7 +346,14 @@ server.tool(
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} cancellation requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -255,19 +363,38 @@ server.tool(
|
||||
{
|
||||
task_id: z.string().describe('The task ID to update'),
|
||||
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.'),
|
||||
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
|
||||
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
|
||||
if (
|
||||
args.schedule_type === 'cron' ||
|
||||
(!args.schedule_type && args.schedule_value)
|
||||
) {
|
||||
if (args.schedule_value) {
|
||||
try {
|
||||
CronExpressionParser.parse(args.schedule_value);
|
||||
} catch {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid cron: "${args.schedule_value}".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -277,7 +404,12 @@ server.tool(
|
||||
const ms = parseInt(args.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid interval: "${args.schedule_value}".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -292,12 +424,21 @@ server.tool(
|
||||
};
|
||||
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;
|
||||
if (args.schedule_type !== undefined)
|
||||
data.schedule_type = args.schedule_type;
|
||||
if (args.schedule_value !== undefined)
|
||||
data.schedule_value = args.schedule_value;
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} update requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -307,15 +448,34 @@ server.tool(
|
||||
|
||||
Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
|
||||
{
|
||||
jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
|
||||
jid: z
|
||||
.string()
|
||||
.describe(
|
||||
'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")',
|
||||
),
|
||||
name: z.string().describe('Display name for the group'),
|
||||
folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
|
||||
folder: z
|
||||
.string()
|
||||
.describe(
|
||||
'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")',
|
||||
),
|
||||
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
|
||||
requiresTrigger: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether messages must start with the trigger word. Default: false (respond to all messages). Set to true for busy groups with many participants where you only want the agent to respond when explicitly mentioned.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
if (!isMain) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Only the main group can register new groups.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -326,13 +486,19 @@ Use available_groups.json to find the JID for a group. The folder name must be c
|
||||
name: args.name,
|
||||
folder: args.folder,
|
||||
trigger: args.trigger,
|
||||
requiresTrigger: args.requiresTrigger ?? false,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }],
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Group "${args.name}" registered. It will start receiving messages immediately.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: qmd
|
||||
description: Search past conversations and documentation. Use when users ask about things mentioned before, past discussions, or need context from history.
|
||||
allowed-tools: Bash(npx qmd:*), Grep, Glob, Read
|
||||
---
|
||||
|
||||
# QMD - Conversation Search
|
||||
|
||||
Search past conversations and documentation in the groups directory.
|
||||
|
||||
## MCP Tools (Preferred)
|
||||
|
||||
QMD MCP server runs on the host at `http://host.docker.internal:8182/mcp`.
|
||||
|
||||
Available tools:
|
||||
- `mcp__qmd__query` - Search with lex/vec/hyde queries
|
||||
- `mcp__qmd__get` - Retrieve document by path or docid
|
||||
- `mcp__qmd__multi_get` - Batch retrieve by glob pattern
|
||||
- `mcp__qmd__status` - Check index health
|
||||
|
||||
Example query:
|
||||
```json
|
||||
{
|
||||
"searches": [
|
||||
{ "type": "lex", "query": "search term" },
|
||||
{ "type": "vec", "query": "natural language question" }
|
||||
],
|
||||
"collections": ["telegram_main"],
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Fallback
|
||||
|
||||
If MCP tools are unavailable, use the QMD CLI directly:
|
||||
|
||||
```bash
|
||||
# Keyword search
|
||||
npx qmd search "search term" -c telegram_main
|
||||
|
||||
# Semantic search (requires embeddings)
|
||||
npx qmd vsearch "natural language question" -c telegram_main
|
||||
|
||||
# Hybrid search with reranking (best quality)
|
||||
npx qmd query "question" -c telegram_main
|
||||
```
|
||||
|
||||
## Fallback: Direct File Search
|
||||
|
||||
If QMD isn't available at all, search conversation files directly:
|
||||
|
||||
```bash
|
||||
# Find conversations containing a term
|
||||
grep -r "term" /workspace/group/conversations/
|
||||
|
||||
# List recent conversations
|
||||
ls -lt /workspace/group/conversations/ | head -10
|
||||
```
|
||||
|
||||
## Conversation Files Location
|
||||
|
||||
- Conversations: `/workspace/group/conversations/*.md`
|
||||
- Documentation: `/workspace/group/docs/*.md`
|
||||
- Group memory: `/workspace/group/CLAUDE.md`
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks "what did we discuss about X"
|
||||
- User mentions something from a past conversation
|
||||
- Need context from previous sessions
|
||||
- Looking up decisions or preferences mentioned before
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -42,7 +42,7 @@ private_key, .secret
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart.
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
@@ -88,6 +88,7 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
|
||||
@@ -83,15 +83,16 @@ Anthropic credentials must be either an API key from console.anthropic.com (`ANT
|
||||
|
||||
## Container Mounts
|
||||
|
||||
Main has read-only access to the project and read-write access to its group folder:
|
||||
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
|
||||
|
||||
| Container Path | Host Path | Access |
|
||||
|----------------|-----------|--------|
|
||||
| `/workspace/project` | Project root | read-only |
|
||||
| `/workspace/project/store` | `store/` | read-write |
|
||||
| `/workspace/group` | `groups/main/` | read-write |
|
||||
|
||||
Key paths inside the container:
|
||||
- `/workspace/project/store/messages.db` - SQLite database
|
||||
- `/workspace/project/store/messages.db` - SQLite database (read-write)
|
||||
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
|
||||
- `/workspace/project/groups/` - All group folders
|
||||
|
||||
@@ -172,10 +173,11 @@ Fields:
|
||||
### Adding a Group
|
||||
|
||||
1. Query the database to find the group's JID
|
||||
2. Use the `register_group` MCP tool with the JID, name, folder, and trigger
|
||||
3. Optionally include `containerConfig` for additional mounts
|
||||
4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
|
||||
5. Optionally create an initial `CLAUDE.md` for the group
|
||||
2. Ask the user whether the group should require a trigger word before registering
|
||||
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
|
||||
4. Optionally include `containerConfig` for additional mounts
|
||||
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
|
||||
6. Optionally create an initial `CLAUDE.md` for the group
|
||||
|
||||
Folder naming convention — channel prefix with underscore separator:
|
||||
- WhatsApp "Family Chat" → `whatsapp_family-chat`
|
||||
@@ -256,7 +258,7 @@ Read `/workspace/project/data/registered_groups.json` and format it nicely.
|
||||
|
||||
## Global Memory
|
||||
|
||||
You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
|
||||
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+115
-132
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.42",
|
||||
"version": "1.2.52",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.42",
|
||||
"version": "1.2.52",
|
||||
"dependencies": {
|
||||
"@onecli-sh/sdk": "^0.2.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
@@ -694,9 +694,9 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -708,9 +708,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -722,9 +722,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -736,9 +736,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -750,9 +750,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -764,9 +764,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -778,9 +778,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -792,9 +792,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -806,9 +806,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -820,9 +820,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -834,9 +834,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -848,9 +848,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -862,9 +862,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -876,9 +876,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -890,9 +890,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -904,9 +904,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -918,9 +918,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -932,9 +932,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -946,9 +946,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -960,9 +960,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -974,9 +974,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -988,9 +988,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1002,9 +1002,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1016,9 +1016,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1030,9 +1030,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1605,10 +1605,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -2605,9 +2606,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2774,9 +2775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2790,31 +2791,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||
"@rollup/rollup-android-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -3157,9 +3158,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3356,24 +3357,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.42",
|
||||
"version": "1.2.52",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="42.4k tokens, 21% of context window">
|
||||
<title>42.4k tokens, 21% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.7k tokens, 22% of context window">
|
||||
<title>43.7k tokens, 22% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">42.4k</text>
|
||||
<text x="74" y="14">42.4k</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.7k</text>
|
||||
<text x="74" y="14">43.7k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Prune stale session artifacts (JSONLs, debug logs, todos, telemetry, group logs).
|
||||
# Safe to run while NanoClaw is live — active sessions are read from the DB.
|
||||
#
|
||||
# Usage: ./scripts/cleanup-sessions.sh [--dry-run]
|
||||
#
|
||||
# Retention:
|
||||
# Session JSONLs + tool-results: 7 days (active session always kept)
|
||||
# Debug logs: 3 days
|
||||
# Todo files: 3 days
|
||||
# Telemetry: 7 days
|
||||
# Group logs: 7 days
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
STORE_DB="$PROJECT_ROOT/store/messages.db"
|
||||
SESSIONS_DIR="$PROJECT_ROOT/data/sessions"
|
||||
GROUPS_DIR="$PROJECT_ROOT/groups"
|
||||
|
||||
DRY_RUN=false
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
TOTAL_FREED=0
|
||||
|
||||
log() { echo "[cleanup] $*"; }
|
||||
|
||||
remove() {
|
||||
local target="$1"
|
||||
if $DRY_RUN; then
|
||||
if [ -d "$target" ]; then
|
||||
size=$(du -sk "$target" 2>/dev/null | cut -f1)
|
||||
else
|
||||
size=$(wc -c < "$target" 2>/dev/null || echo 0)
|
||||
size=$((size / 1024))
|
||||
fi
|
||||
TOTAL_FREED=$((TOTAL_FREED + size))
|
||||
log "would remove: $target (${size}K)"
|
||||
else
|
||||
if [ -d "$target" ]; then
|
||||
size=$(du -sk "$target" 2>/dev/null | cut -f1)
|
||||
rm -rf "$target"
|
||||
else
|
||||
size=$(wc -c < "$target" 2>/dev/null || echo 0)
|
||||
size=$((size / 1024))
|
||||
rm -f "$target"
|
||||
fi
|
||||
TOTAL_FREED=$((TOTAL_FREED + size))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Collect active session IDs from the database ---
|
||||
|
||||
if [ ! -f "$STORE_DB" ]; then
|
||||
log "ERROR: database not found at $STORE_DB"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE_IDS=$(sqlite3 "$STORE_DB" "SELECT session_id FROM sessions;" 2>/dev/null || true)
|
||||
|
||||
is_active() {
|
||||
echo "$ACTIVE_IDS" | grep -qF "$1"
|
||||
}
|
||||
|
||||
# --- Prune session JSONLs and tool-results dirs ---
|
||||
|
||||
for group_dir in "$SESSIONS_DIR"/*/; do
|
||||
[ -d "$group_dir" ] || continue
|
||||
jsonl_dir="$group_dir/.claude/projects/-workspace-group"
|
||||
[ -d "$jsonl_dir" ] || continue
|
||||
|
||||
for jsonl in "$jsonl_dir"/*.jsonl; do
|
||||
[ -f "$jsonl" ] || continue
|
||||
id=$(basename "$jsonl" .jsonl)
|
||||
|
||||
# Never delete the active session
|
||||
if is_active "$id"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Only delete if older than 7 days
|
||||
if [ -n "$(find "$jsonl" -mtime +7 2>/dev/null)" ]; then
|
||||
remove "$jsonl"
|
||||
# Remove matching tool-results directory
|
||||
[ -d "$jsonl_dir/$id" ] && remove "$jsonl_dir/$id"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# --- Prune debug logs (>3 days, skip files named after active sessions) ---
|
||||
|
||||
for group_dir in "$SESSIONS_DIR"/*/; do
|
||||
debug_dir="$group_dir/.claude/debug"
|
||||
[ -d "$debug_dir" ] || continue
|
||||
while IFS= read -r -d '' f; do
|
||||
fname=$(basename "$f" .txt)
|
||||
is_active "$fname" && continue
|
||||
remove "$f"
|
||||
done < <(find "$debug_dir" -type f -mtime +3 ! -name "latest" -print0 2>/dev/null)
|
||||
done
|
||||
|
||||
# --- Prune todo files (>3 days, skip files named after active sessions) ---
|
||||
|
||||
for group_dir in "$SESSIONS_DIR"/*/; do
|
||||
todos_dir="$group_dir/.claude/todos"
|
||||
[ -d "$todos_dir" ] || continue
|
||||
while IFS= read -r -d '' f; do
|
||||
fname=$(basename "$f" .json)
|
||||
# Todo filenames are like {session_id}-agent-{session_id}.json
|
||||
for aid in $ACTIVE_IDS; do
|
||||
if [[ "$fname" == *"$aid"* ]]; then
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
remove "$f"
|
||||
done < <(find "$todos_dir" -type f -mtime +3 -print0 2>/dev/null)
|
||||
done
|
||||
|
||||
# --- Prune telemetry (>7 days, skip files named after active sessions) ---
|
||||
|
||||
for group_dir in "$SESSIONS_DIR"/*/; do
|
||||
telem_dir="$group_dir/.claude/telemetry"
|
||||
[ -d "$telem_dir" ] || continue
|
||||
while IFS= read -r -d '' f; do
|
||||
fname=$(basename "$f")
|
||||
for aid in $ACTIVE_IDS; do
|
||||
if [[ "$fname" == *"$aid"* ]]; then
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
remove "$f"
|
||||
done < <(find "$telem_dir" -type f -mtime +7 -print0 2>/dev/null)
|
||||
done
|
||||
|
||||
# --- Prune group logs (>7 days) ---
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
remove "$f"
|
||||
done < <(find "$GROUPS_DIR"/*/logs -type f -mtime +7 -print0 2>/dev/null)
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
if $DRY_RUN; then
|
||||
log "DRY RUN complete — would free ~${TOTAL_FREED}K"
|
||||
else
|
||||
log "Done — freed ~${TOTAL_FREED}K"
|
||||
fi
|
||||
@@ -121,6 +121,7 @@ check_build_tools() {
|
||||
log "=== Bootstrap started ==="
|
||||
|
||||
detect_platform
|
||||
|
||||
check_node
|
||||
install_deps
|
||||
check_build_tools
|
||||
@@ -135,6 +136,12 @@ elif [ "$NATIVE_OK" = "false" ]; then
|
||||
STATUS="native_failed"
|
||||
fi
|
||||
|
||||
# Anonymous setup start event (non-blocking, best-effort)
|
||||
curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"setup_start\",\"distinct_id\":\"$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo unknown)\",\"properties\":{\"platform\":\"$PLATFORM\",\"is_wsl\":\"$IS_WSL\",\"is_root\":\"$IS_ROOT\",\"node_version\":\"$NODE_VERSION\",\"deps_ok\":\"$DEPS_OK\",\"native_ok\":\"$NATIVE_OK\",\"has_build_tools\":\"$HAS_BUILD_TOOLS\"}}" \
|
||||
>/dev/null 2>&1 &
|
||||
|
||||
cat <<EOF
|
||||
=== NANOCLAW SETUP: BOOTSTRAP ===
|
||||
PLATFORM: $PLATFORM
|
||||
|
||||
+1
-2
@@ -51,8 +51,7 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
export const ONECLI_URL =
|
||||
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
|
||||
export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
|
||||
|
||||
+20
-1
@@ -68,7 +68,7 @@ function buildVolumeMounts(
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (group folder, IPC, .claude/) are mounted separately below.
|
||||
// (store, group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
@@ -89,12 +89,31 @@ function buildVolumeMounts(
|
||||
});
|
||||
}
|
||||
|
||||
// Main gets writable access to the store (SQLite DB) so it can
|
||||
// query and write to the database directly.
|
||||
const storeDir = path.join(projectRoot, 'store');
|
||||
mounts.push({
|
||||
hostPath: storeDir,
|
||||
containerPath: '/workspace/project/store',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory — writable for main so it can update shared context
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
|
||||
@@ -48,8 +48,12 @@ describe('stopContainer', () => {
|
||||
});
|
||||
|
||||
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; 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();
|
||||
});
|
||||
|
||||
@@ -142,6 +142,86 @@ describe('storeMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- reply context persistence ---
|
||||
|
||||
describe('reply context', () => {
|
||||
it('stores and retrieves reply_to fields', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
storeMessage({
|
||||
id: 'reply-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123',
|
||||
sender_name: 'Alice',
|
||||
content: 'Yes, on my way!',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
reply_to_message_id: '42',
|
||||
reply_to_message_content: 'Are you coming tonight?',
|
||||
reply_to_sender_name: 'Bob',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].reply_to_message_id).toBe('42');
|
||||
expect(messages[0].reply_to_message_content).toBe(
|
||||
'Are you coming tonight?',
|
||||
);
|
||||
expect(messages[0].reply_to_sender_name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('returns null for messages without reply context', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'no-reply',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123',
|
||||
sender_name: 'Alice',
|
||||
content: 'Just a normal message',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].reply_to_message_id).toBeNull();
|
||||
expect(messages[0].reply_to_message_content).toBeNull();
|
||||
expect(messages[0].reply_to_sender_name).toBeNull();
|
||||
});
|
||||
|
||||
it('retrieves reply context via getNewMessages', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
storeMessage({
|
||||
id: 'reply-2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '456',
|
||||
sender_name: 'Carol',
|
||||
content: 'Agreed',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
reply_to_message_id: '99',
|
||||
reply_to_message_content: 'We should meet',
|
||||
reply_to_sender_name: 'Dave',
|
||||
});
|
||||
|
||||
const { messages } = getNewMessages(
|
||||
['group@g.us'],
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].reply_to_message_id).toBe('99');
|
||||
expect(messages[0].reply_to_sender_name).toBe('Dave');
|
||||
});
|
||||
});
|
||||
|
||||
// --- getMessagesSince ---
|
||||
|
||||
describe('getMessagesSince', () => {
|
||||
|
||||
@@ -146,6 +146,17 @@ function createSchema(database: Database.Database): void {
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
|
||||
// Add reply context columns if they don't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT`);
|
||||
database.exec(
|
||||
`ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`,
|
||||
);
|
||||
database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
}
|
||||
|
||||
export function initDatabase(): void {
|
||||
@@ -274,7 +285,7 @@ export function setLastGroupSync(): void {
|
||||
*/
|
||||
export function storeMessage(msg: NewMessage): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, reply_to_message_id, reply_to_message_content, reply_to_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
msg.id,
|
||||
msg.chat_jid,
|
||||
@@ -284,6 +295,9 @@ export function storeMessage(msg: NewMessage): void {
|
||||
msg.timestamp,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
msg.reply_to_message_id ?? null,
|
||||
msg.reply_to_message_content ?? null,
|
||||
msg.reply_to_sender_name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,7 +342,8 @@ export function getNewMessages(
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
|
||||
reply_to_message_id, reply_to_message_content, reply_to_sender_name
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
@@ -361,7 +376,8 @@ export function getMessagesSince(
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
|
||||
reply_to_message_id, reply_to_message_content, reply_to_sender_name
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
@@ -561,6 +577,10 @@ export function setSession(groupFolder: string, sessionId: string): void {
|
||||
).run(groupFolder, sessionId);
|
||||
}
|
||||
|
||||
export function deleteSession(groupFolder: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
||||
}
|
||||
|
||||
export function getAllSessions(): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||
|
||||
@@ -115,6 +115,62 @@ describe('formatMessages', () => {
|
||||
expect(result).toContain('<messages>\n\n</messages>');
|
||||
});
|
||||
|
||||
it('renders reply context as quoted_message element', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
content: 'Yes, on my way!',
|
||||
reply_to_message_id: '42',
|
||||
reply_to_message_content: 'Are you coming tonight?',
|
||||
reply_to_sender_name: 'Bob',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).toContain(
|
||||
'<quoted_message from="Bob">Are you coming tonight?</quoted_message>',
|
||||
);
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
it('omits reply attributes when no reply context', () => {
|
||||
const result = formatMessages([makeMsg()], TZ);
|
||||
expect(result).not.toContain('reply_to');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('omits quoted_message when content is missing but id is present', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
reply_to_message_id: '42',
|
||||
reply_to_sender_name: 'Bob',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('escapes special characters in reply context', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
reply_to_message_id: '1',
|
||||
reply_to_message_content: '<script>alert("xss")</script>',
|
||||
reply_to_sender_name: 'A & B',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('from="A & B"');
|
||||
expect(result).toContain(
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts timestamps to local time for given timezone', () => {
|
||||
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
|
||||
const result = formatMessages(
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
deleteSession,
|
||||
getAllTasks,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
loadSenderAllowlist,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
import { startSessionCleanup } from './session-cleanup.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -402,6 +404,26 @@ async function runAgent(
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
// Detect stale/corrupt session — clear it so the next retry starts fresh.
|
||||
// The session .jsonl can go missing after a crash mid-write, manual
|
||||
// deletion, or disk-full. The existing backoff in group-queue.ts
|
||||
// handles the retry; we just need to remove the broken session ID.
|
||||
const isStaleSession =
|
||||
sessionId &&
|
||||
output.error &&
|
||||
/no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(
|
||||
output.error,
|
||||
);
|
||||
|
||||
if (isStaleSession) {
|
||||
logger.warn(
|
||||
{ group: group.name, staleSessionId: sessionId, error: output.error },
|
||||
'Stale session detected — clearing for next retry',
|
||||
);
|
||||
delete sessions[group.folder];
|
||||
deleteSession(group.folder);
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ group: group.name, error: output.error },
|
||||
'Container agent error',
|
||||
@@ -725,6 +747,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
},
|
||||
});
|
||||
startSessionCleanup();
|
||||
queue.setProcessMessagesFn(processGroupMessages);
|
||||
recoverPendingMessages();
|
||||
startMessageLoop().catch((err) => {
|
||||
|
||||
+3
-3
@@ -441,9 +441,9 @@ export async function processTaskIpc(
|
||||
);
|
||||
break;
|
||||
}
|
||||
// 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.
|
||||
// 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,
|
||||
|
||||
+8
-1
@@ -16,7 +16,14 @@ export function formatMessages(
|
||||
): string {
|
||||
const lines = messages.map((m) => {
|
||||
const displayTime = formatLocalTime(m.timestamp, timezone);
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
|
||||
const replyAttr = m.reply_to_message_id
|
||||
? ` reply_to="${escapeXml(m.reply_to_message_id)}"`
|
||||
: '';
|
||||
const replySnippet =
|
||||
m.reply_to_message_content && m.reply_to_sender_name
|
||||
? `\n <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
|
||||
: '';
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
|
||||
});
|
||||
|
||||
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execFile } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SCRIPT_PATH = path.resolve(process.cwd(), 'scripts/cleanup-sessions.sh');
|
||||
|
||||
function runCleanup(): void {
|
||||
execFile('/bin/bash', [SCRIPT_PATH], { timeout: 60_000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'Session cleanup failed');
|
||||
return;
|
||||
}
|
||||
const summary = stdout.trim().split('\n').pop();
|
||||
if (summary) logger.info(summary);
|
||||
});
|
||||
}
|
||||
|
||||
export function startSessionCleanup(): void {
|
||||
// Run once at startup (delayed 30s to not compete with init)
|
||||
setTimeout(runCleanup, 30_000);
|
||||
// Then every 24 hours
|
||||
setInterval(runCleanup, CLEANUP_INTERVAL);
|
||||
}
|
||||
@@ -51,6 +51,10 @@ export interface NewMessage {
|
||||
timestamp: string;
|
||||
is_from_me?: boolean;
|
||||
is_bot_message?: boolean;
|
||||
thread_id?: string;
|
||||
reply_to_message_id?: string;
|
||||
reply_to_message_content?: string;
|
||||
reply_to_sender_name?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
|
||||
Reference in New Issue
Block a user