mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3703c9decb | |||
| 426ae0285e | |||
| 7ef1c4f5e0 | |||
| f60bb3c3d5 | |||
| 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 |
@@ -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`
|
||||
@@ -200,7 +200,28 @@ Ask them to let you know when done.
|
||||
|
||||
### 4b. Apple Container → Native Credential Proxy
|
||||
|
||||
Apple Container is not compatible with OneCLI. Invoke `/use-native-credential-proxy` to set up the built-in credential proxy instead. That skill handles credential collection, `.env` configuration, and verification.
|
||||
Apple Container is not compatible with OneCLI. The credential proxy code is already included in the apple-container branch — do NOT invoke `/use-native-credential-proxy` (it would conflict with already-applied code).
|
||||
|
||||
Instead, just configure the credentials in `.env`:
|
||||
|
||||
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
|
||||
|
||||
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. Run `claude setup-token` in another terminal to get your token."
|
||||
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
|
||||
|
||||
For subscription: tell the user to run `claude setup-token` in another terminal. Stop and wait for the user to confirm they have completed this step successfully before proceeding.
|
||||
|
||||
Once confirmed, add the token to `.env`:
|
||||
```bash
|
||||
echo 'CLAUDE_CODE_OAUTH_TOKEN=<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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
|
||||
|
||||
;; Author: NanoClaw
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "27.1"))
|
||||
;; Keywords: ai, assistant, chat
|
||||
;;
|
||||
;; Vanilla Emacs (init.el):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Doom Emacs (config.el):
|
||||
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
;; (map! :leader
|
||||
;; :prefix ("N" . "NanoClaw")
|
||||
;; :desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
;; :desc "Send org" "o" #'nanoclaw-org-send)
|
||||
;; ;; Evil users: teach evil about the C-c C-c send binding
|
||||
;; (after! evil
|
||||
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
|
||||
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'org)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Customization
|
||||
|
||||
(defgroup nanoclaw nil
|
||||
"NanoClaw AI assistant interface."
|
||||
:group 'tools
|
||||
:prefix "nanoclaw-")
|
||||
|
||||
(defcustom nanoclaw-host "localhost"
|
||||
"Hostname where NanoClaw is running."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-port 8766
|
||||
"Port for the NanoClaw Emacs channel HTTP server."
|
||||
:type 'integer
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-auth-token nil
|
||||
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
|
||||
Leave nil if EMACS_AUTH_TOKEN is not set."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-poll-interval 1.5
|
||||
"Seconds between response polls when waiting for a reply."
|
||||
:type 'number
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-agent-name "Andy"
|
||||
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-convert-to-org t
|
||||
"When non-nil, convert agent responses to org-mode format.
|
||||
Uses pandoc when available; falls back to regex substitutions."
|
||||
:type 'boolean
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-timestamp-format "%H:%M"
|
||||
"Format string for timestamps shown next to agent replies in the chat buffer.
|
||||
Passed to `format-time-string'. Set to nil to suppress timestamps."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Formatting helpers
|
||||
|
||||
(defun nanoclaw--to-org (text)
|
||||
"Convert TEXT (markdown or plain) to org-mode markup.
|
||||
Tries pandoc -f gfm -t org when available; falls back to regex."
|
||||
(if (not nanoclaw-convert-to-org)
|
||||
text
|
||||
(if (executable-find "pandoc")
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(let* ((coding-system-for-read 'utf-8)
|
||||
(coding-system-for-write 'utf-8)
|
||||
(exit (call-process-region
|
||||
(point-min) (point-max)
|
||||
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
|
||||
(if (zerop exit)
|
||||
(string-trim (buffer-string))
|
||||
text)))
|
||||
(nanoclaw--md-to-org-regex text))))
|
||||
|
||||
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
|
||||
;; Agents responding on this channel must output markdown, not org-mode syntax.
|
||||
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
|
||||
;; re-converted to /bold/ by the italic rule.
|
||||
(defun nanoclaw--md-to-org-regex (text)
|
||||
"Lightweight markdown → org conversion using regexp substitutions."
|
||||
(let ((s text))
|
||||
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
|
||||
;; (must run before inline-code to avoid mangling backticks)
|
||||
(setq s (replace-regexp-in-string
|
||||
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
|
||||
(lambda (m)
|
||||
(let ((lang (match-string 1 m))
|
||||
(body (match-string 2 m)))
|
||||
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
|
||||
"\n" body "#+end_src")))
|
||||
s t))
|
||||
;; Bold **text** → *text*, italic *text* → /text/
|
||||
;; Two-pass to prevent the italic regex from re-matching the bold result:
|
||||
;; 1. Mark bold spans with a placeholder (control char \x01)
|
||||
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
|
||||
;; 2. Convert remaining single-star spans to italic
|
||||
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
|
||||
;; 3. Resolve bold placeholders to org bold markers
|
||||
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
|
||||
;; Strikethrough ~~text~~ → +text+
|
||||
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
|
||||
;; Underline __text__ → _text_
|
||||
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
|
||||
;; Inline code `code` → ~code~
|
||||
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
|
||||
;; ATX headings ## … → ** …
|
||||
(setq s (replace-regexp-in-string
|
||||
"^\\(#+\\) "
|
||||
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
|
||||
s))
|
||||
;; Links [text](url) → [[url][text]]
|
||||
(setq s (replace-regexp-in-string
|
||||
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
|
||||
s))
|
||||
|
||||
(defun nanoclaw--format-timestamp ()
|
||||
"Return a formatted timestamp string, or nil if disabled."
|
||||
(when nanoclaw-timestamp-format
|
||||
(format-time-string nanoclaw-timestamp-format)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal state
|
||||
|
||||
(defvar nanoclaw--poll-timer nil
|
||||
"Timer used to poll for responses in the chat buffer.")
|
||||
|
||||
(defvar nanoclaw--last-timestamp 0
|
||||
"Epoch ms of the most recently received message.")
|
||||
|
||||
(defvar nanoclaw--pending nil
|
||||
"Non-nil while waiting for a response.")
|
||||
|
||||
(defvar-local nanoclaw--thinking-dot-count 0
|
||||
"Dot cycle counter for the animated thinking indicator.")
|
||||
|
||||
(defvar-local nanoclaw--input-beg nil
|
||||
"Marker for the start of the current user input area.")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
|
||||
(defun nanoclaw--url (path)
|
||||
"Return the full URL for PATH on the NanoClaw server."
|
||||
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
|
||||
|
||||
(defun nanoclaw--headers ()
|
||||
"Return alist of HTTP headers for NanoClaw requests."
|
||||
(let ((hdrs '(("Content-Type" . "application/json"))))
|
||||
(when nanoclaw-auth-token
|
||||
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
|
||||
hdrs))
|
||||
|
||||
(defun nanoclaw--post (text callback)
|
||||
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
|
||||
(let* ((url-request-method "POST")
|
||||
(url-request-extra-headers (nanoclaw--headers))
|
||||
(url-request-data (encode-coding-string
|
||||
(json-encode `((text . ,text)))
|
||||
'utf-8)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url "/api/message")
|
||||
(lambda (status)
|
||||
(if (plist-get status :error)
|
||||
(message "NanoClaw: POST error %s" (plist-get status :error))
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let ((data (ignore-errors (json-read))))
|
||||
(funcall callback data))))
|
||||
nil t t)))
|
||||
|
||||
(defun nanoclaw--poll (since callback)
|
||||
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
|
||||
(let* ((url-request-method "GET")
|
||||
(url-request-extra-headers (nanoclaw--headers)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url (format "/api/messages?since=%d" since))
|
||||
(lambda (status)
|
||||
(unless (plist-get status :error)
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
|
||||
(body (decode-coding-string raw 'utf-8))
|
||||
(data (ignore-errors (json-read-from-string body)))
|
||||
(msgs (cdr (assq 'messages data))))
|
||||
(when msgs (funcall callback (append msgs nil))))))
|
||||
nil t t)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Chat buffer
|
||||
|
||||
(defvar nanoclaw-chat-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'newline)
|
||||
(define-key map (kbd "<return>") #'newline)
|
||||
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
|
||||
map)
|
||||
"Keymap for `nanoclaw-chat-mode'.")
|
||||
|
||||
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
|
||||
"Major mode for the NanoClaw chat buffer.
|
||||
Derives from org-mode so that org markup (headings, bold, code blocks,
|
||||
etc.) is fontified automatically. RET and <return> insert plain newlines
|
||||
for multi-line input; send with C-c C-c."
|
||||
(setq-local word-wrap t)
|
||||
(visual-line-mode 1)
|
||||
;; Disable org features that conflict with a linear chat buffer
|
||||
(setq-local org-return-follows-link nil)
|
||||
(setq-local org-cycle-emulate-tab nil)
|
||||
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
|
||||
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
(defun nanoclaw--advance-input-beg ()
|
||||
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))))
|
||||
|
||||
(defun nanoclaw--chat-buffer ()
|
||||
"Return the NanoClaw chat buffer, creating it if necessary."
|
||||
(or (get-buffer "*NanoClaw*")
|
||||
(with-current-buffer (get-buffer-create "*NanoClaw*")
|
||||
(nanoclaw-chat-mode)
|
||||
(set-buffer-file-coding-system 'utf-8)
|
||||
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
|
||||
(nanoclaw--insert-header)
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))
|
||||
(current-buffer))))
|
||||
|
||||
(defun nanoclaw--insert-header ()
|
||||
"Insert the welcome header into the chat buffer."
|
||||
(let ((inhibit-read-only t))
|
||||
(insert (propertize
|
||||
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
|
||||
nanoclaw-agent-name)
|
||||
'face 'font-lock-comment-face))))
|
||||
|
||||
(defun nanoclaw--chat-insert (speaker text)
|
||||
"Append SPEAKER: TEXT to the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let* ((inhibit-read-only t)
|
||||
(is-agent (not (string= speaker "You")))
|
||||
(display-text (if is-agent (nanoclaw--to-org text) text))
|
||||
(ts (nanoclaw--format-timestamp))
|
||||
(label (if ts (format "%s [%s]" speaker ts) speaker))
|
||||
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
|
||||
(goto-char (point-max))
|
||||
(insert (propertize (concat label ": ") 'face face))
|
||||
(insert display-text "\n\n")
|
||||
(goto-char (point-max))
|
||||
(when is-agent
|
||||
(nanoclaw--advance-input-beg)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-chat ()
|
||||
"Open the NanoClaw chat buffer."
|
||||
(interactive)
|
||||
(pop-to-buffer (nanoclaw--chat-buffer))
|
||||
(goto-char (point-max)))
|
||||
|
||||
(defun nanoclaw-chat-send ()
|
||||
"Send the accumulated input area as a message to NanoClaw.
|
||||
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
|
||||
(interactive)
|
||||
(when nanoclaw--pending
|
||||
(message "NanoClaw: waiting for previous response...")
|
||||
(cl-return-from nanoclaw-chat-send))
|
||||
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
|
||||
(marker-position nanoclaw--input-beg)
|
||||
(line-beginning-position)))
|
||||
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
|
||||
(when (string-empty-p text)
|
||||
(user-error "Nothing to send"))
|
||||
(let ((inhibit-read-only t))
|
||||
(delete-region beg (point-max)))
|
||||
(nanoclaw--chat-insert "You" text)
|
||||
(nanoclaw--advance-input-beg)
|
||||
(setq nanoclaw--pending t)
|
||||
(nanoclaw--post text
|
||||
(lambda (data)
|
||||
(when data
|
||||
(setq nanoclaw--last-timestamp
|
||||
(or (cdr (assq 'timestamp data))
|
||||
nanoclaw--last-timestamp))
|
||||
(nanoclaw--start-thinking)
|
||||
(nanoclaw--start-poll))))))
|
||||
|
||||
(defun nanoclaw--start-poll ()
|
||||
"Start polling for new messages."
|
||||
(nanoclaw--stop-poll)
|
||||
(setq nanoclaw--poll-timer
|
||||
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
|
||||
#'nanoclaw--poll-tick)))
|
||||
|
||||
(defun nanoclaw--stop-poll ()
|
||||
"Stop the polling timer."
|
||||
(when nanoclaw--poll-timer
|
||||
(cancel-timer nanoclaw--poll-timer)
|
||||
(setq nanoclaw--poll-timer nil)))
|
||||
|
||||
(defun nanoclaw--start-thinking ()
|
||||
"Insert an animated thinking indicator at the end of the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(setq nanoclaw--thinking-dot-count 1)
|
||||
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))
|
||||
|
||||
(defun nanoclaw--tick-thinking ()
|
||||
"Advance the dot animation in the thinking indicator."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(when nanoclaw--pending
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(let* ((end (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))
|
||||
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
|
||||
(setq nanoclaw--thinking-dot-count n)
|
||||
(delete-region pos end)
|
||||
(save-excursion
|
||||
(goto-char pos)
|
||||
(insert (propertize
|
||||
(format "%s: %s\n\n" nanoclaw-agent-name
|
||||
(make-string n ?.))
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))))))))
|
||||
|
||||
(defun nanoclaw--clear-thinking ()
|
||||
"Remove the thinking indicator from the chat buffer."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(delete-region pos (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))))))))
|
||||
|
||||
(defun nanoclaw--poll-tick ()
|
||||
"Poll for new messages and insert them into the chat buffer."
|
||||
(nanoclaw--tick-thinking)
|
||||
(nanoclaw--poll
|
||||
nanoclaw--last-timestamp
|
||||
(lambda (msgs)
|
||||
(dolist (msg msgs)
|
||||
(let ((text (cdr (assq 'text msg)))
|
||||
(ts (cdr (assq 'timestamp msg))))
|
||||
(when (and text (> ts nanoclaw--last-timestamp))
|
||||
(setq nanoclaw--last-timestamp ts)
|
||||
(nanoclaw--clear-thinking)
|
||||
(nanoclaw--chat-insert nanoclaw-agent-name text))))
|
||||
(when msgs
|
||||
(setq nanoclaw--pending nil)
|
||||
(nanoclaw--stop-poll)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Org integration
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-org-send ()
|
||||
"Send the current org subtree to NanoClaw and insert the response as a child.
|
||||
|
||||
If a region is active, send the region text instead."
|
||||
(interactive)
|
||||
(unless (derived-mode-p 'org-mode)
|
||||
(user-error "Not in an org-mode buffer"))
|
||||
(let ((text (if (use-region-p)
|
||||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||||
(nanoclaw--org-subtree-text))))
|
||||
(when (string-empty-p (string-trim text))
|
||||
(user-error "Nothing to send"))
|
||||
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
|
||||
(let ((marker (point-marker))
|
||||
(buf (current-buffer)))
|
||||
(nanoclaw--post
|
||||
text
|
||||
(lambda (data)
|
||||
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
|
||||
(level (with-current-buffer buf
|
||||
(save-excursion (goto-char marker) (org-outline-level))))
|
||||
(ph (with-current-buffer buf
|
||||
(save-excursion
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-placeholder level)))))
|
||||
(nanoclaw--poll-until-response
|
||||
ts
|
||||
(lambda (response)
|
||||
(with-current-buffer buf
|
||||
(save-excursion
|
||||
(when (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil))
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-response response))))
|
||||
(lambda ()
|
||||
(message "NanoClaw: timed out waiting for response")
|
||||
(when (marker-buffer ph)
|
||||
(with-current-buffer (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil)))))))))))
|
||||
|
||||
(defun nanoclaw--org-insert-placeholder (level)
|
||||
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
|
||||
(org-back-to-heading t)
|
||||
(org-end-of-subtree t t)
|
||||
(let ((beg (point)))
|
||||
(insert "\n" (make-string (1+ level) ?*) " "
|
||||
nanoclaw-agent-name " [processing...]\n\n")
|
||||
(copy-marker beg)))
|
||||
|
||||
(defun nanoclaw--org-subtree-text ()
|
||||
"Return the text of the org subtree at point (heading + body)."
|
||||
(org-with-wide-buffer
|
||||
(org-back-to-heading t)
|
||||
(let ((start (point))
|
||||
(end (progn (org-end-of-subtree t t) (point))))
|
||||
(buffer-substring-no-properties start end))))
|
||||
|
||||
(defun nanoclaw--org-insert-response (text)
|
||||
"Insert TEXT as a child org heading under the current subtree."
|
||||
(org-back-to-heading t)
|
||||
(let* ((level (org-outline-level))
|
||||
(child-stars (make-string (1+ level) ?*))
|
||||
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
|
||||
(body (nanoclaw--to-org text)))
|
||||
(org-end-of-subtree t t)
|
||||
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
|
||||
body "\n")))
|
||||
|
||||
(defun nanoclaw--now-ms ()
|
||||
"Return current time as milliseconds since epoch."
|
||||
(let ((time (current-time)))
|
||||
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
|
||||
(/ (caddr time) 1000))))
|
||||
|
||||
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
|
||||
"Poll until a message newer than SINCE arrives, then call CALLBACK.
|
||||
Calls TIMEOUT-FN after 60 attempts (~90s)."
|
||||
(let ((n (or attempts 0)))
|
||||
(if (>= n 60)
|
||||
(funcall timeout-fn)
|
||||
(nanoclaw--poll
|
||||
since
|
||||
(lambda (msgs)
|
||||
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
|
||||
msgs)))
|
||||
(if fresh
|
||||
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
|
||||
fresh "\n")))
|
||||
(funcall callback text))
|
||||
(run-with-timer nanoclaw-poll-interval nil
|
||||
#'nanoclaw--poll-until-response
|
||||
since callback timeout-fn (1+ n)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(provide 'nanoclaw)
|
||||
;;; nanoclaw.el ends here
|
||||
Generated
+112
-129
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.42",
|
||||
"version": "1.2.43",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.42",
|
||||
"version": "1.2.43",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
@@ -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.43",
|
||||
"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="42.6k tokens, 21% of context window">
|
||||
<title>42.6k tokens, 21% 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">42.6k</text>
|
||||
<text x="74" y="14">42.6k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,531 +0,0 @@
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (hoisted — must appear before any imports of the modules they replace) ---
|
||||
|
||||
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
|
||||
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
GROUPS_DIR: '/tmp/test-groups',
|
||||
}));
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../db.js', () => ({ setRegisteredGroup: vi.fn() }));
|
||||
|
||||
// Stub out all filesystem calls so tests never touch disk.
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
// Simulate missing symlink by default — triggers creation path
|
||||
lstatSync: vi.fn(() => {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
symlinkSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { setRegisteredGroup } from '../db.js';
|
||||
import type { ChannelOpts } from './registry.js';
|
||||
import { EmacsBridgeChannel } from './emacs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
function createTestOpts(overrides?: Partial<ChannelOpts>): ChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'main:jid': {
|
||||
name: 'main',
|
||||
folder: 'main',
|
||||
trigger: '',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
isMain: true,
|
||||
},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Make an HTTP request to the test server; returns status code and parsed body. */
|
||||
async function req(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
};
|
||||
const request = http.request(
|
||||
{ host: '127.0.0.1', port, method, path, headers },
|
||||
(res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, data: raw });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the actual bound port after connect() (server listens on port 0). */
|
||||
function boundPort(channel: EmacsBridgeChannel): number {
|
||||
return (((channel as any).server as http.Server).address() as AddressInfo)
|
||||
.port;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EmacsBridgeChannel', () => {
|
||||
let opts: ChannelOpts;
|
||||
let channel: EmacsBridgeChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
opts = createTestOpts();
|
||||
// Port 0 tells the OS to pick a free ephemeral port — no conflicts between test runs
|
||||
channel = new EmacsBridgeChannel(0, null, opts);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (channel.isConnected()) await channel.disconnect();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('connect / disconnect / isConnected', () => {
|
||||
it('isConnected returns false before connect', () => {
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected returns true after connect', async () => {
|
||||
await channel.connect();
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('isConnected returns false after disconnect', async () => {
|
||||
await channel.connect();
|
||||
await channel.disconnect();
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnect is a no-op when not connected', async () => {
|
||||
await expect(channel.disconnect()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('ownsJid', () => {
|
||||
it('returns true for emacs:default', () => {
|
||||
expect(channel.ownsJid('emacs:default')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-emacs JIDs', () => {
|
||||
expect(channel.ownsJid('tg:123456')).toBe(false);
|
||||
expect(channel.ownsJid('main:jid')).toBe(false);
|
||||
expect(channel.ownsJid('')).toBe(false);
|
||||
expect(channel.ownsJid('emacs:other')).toBe(false);
|
||||
expect(channel.ownsJid('123456@g.us')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('group auto-registration', () => {
|
||||
it('calls setRegisteredGroup when emacs:default is absent', async () => {
|
||||
await channel.connect();
|
||||
expect(setRegisteredGroup).toHaveBeenCalledWith(
|
||||
'emacs:default',
|
||||
expect.objectContaining({
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
requiresTrigger: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('mutates the live registeredGroups map immediately (no restart needed)', async () => {
|
||||
const groups: Record<string, any> = {};
|
||||
const localOpts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => groups),
|
||||
});
|
||||
const c = new EmacsBridgeChannel(0, null, localOpts);
|
||||
await c.connect();
|
||||
expect(groups['emacs:default']).toBeDefined();
|
||||
await c.disconnect();
|
||||
});
|
||||
|
||||
it('skips registration when emacs:default is already present', async () => {
|
||||
const localOpts = createTestOpts({
|
||||
registeredGroups: vi.fn(() => ({
|
||||
'emacs:default': {
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
trigger: '',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const c = new EmacsBridgeChannel(0, null, localOpts);
|
||||
await c.connect();
|
||||
expect(setRegisteredGroup).not.toHaveBeenCalled();
|
||||
await c.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('POST /api/message', () => {
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
port = boundPort(channel);
|
||||
});
|
||||
|
||||
it('returns 200 with messageId and timestamp for valid text', async () => {
|
||||
const { status, data } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hello' }),
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveProperty('messageId');
|
||||
expect(data).toHaveProperty('timestamp');
|
||||
expect(typeof data.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('calls opts.onMessage with correct structure', async () => {
|
||||
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'ping' }));
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'emacs:default',
|
||||
expect.objectContaining({
|
||||
chat_jid: 'emacs:default',
|
||||
content: 'ping',
|
||||
sender: 'emacs',
|
||||
sender_name: 'Emacs',
|
||||
is_from_me: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls opts.onChatMetadata before opts.onMessage', async () => {
|
||||
const order: string[] = [];
|
||||
(opts.onChatMetadata as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
order.push('meta'),
|
||||
);
|
||||
(opts.onMessage as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
order.push('msg'),
|
||||
);
|
||||
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
|
||||
expect(order).toEqual(['meta', 'msg']);
|
||||
});
|
||||
|
||||
it('returns 400 for empty text', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: '' }),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for whitespace-only text', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: ' ' }),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', 'not-json');
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/unknown',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GET /api/messages', () => {
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
port = boundPort(channel);
|
||||
});
|
||||
|
||||
it('returns empty messages array when nothing has been sent', async () => {
|
||||
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ messages: [] });
|
||||
});
|
||||
|
||||
it('returns messages added via sendMessage', async () => {
|
||||
await channel.sendMessage('emacs:default', 'hello back');
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(data.messages).toHaveLength(1);
|
||||
expect(data.messages[0].text).toBe('hello back');
|
||||
});
|
||||
|
||||
it('filters out messages at or before the since timestamp', async () => {
|
||||
await channel.sendMessage('emacs:default', 'old');
|
||||
// Capture `since` after the first push, then wait to guarantee the
|
||||
// second push lands at a strictly later timestamp
|
||||
const since = Date.now();
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
await channel.sendMessage('emacs:default', 'new');
|
||||
|
||||
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
|
||||
expect(data.messages.map((m: any) => m.text)).not.toContain('old');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('new');
|
||||
});
|
||||
|
||||
it('caps buffer at 200 messages, dropping the oldest', async () => {
|
||||
for (let i = 0; i < 201; i++) {
|
||||
await channel.sendMessage('emacs:default', `msg-${i}`);
|
||||
}
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(data.messages).toHaveLength(200);
|
||||
// msg-0 was the first in and should have been evicted
|
||||
expect(data.messages.map((m: any) => m.text)).not.toContain('msg-0');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('msg-1');
|
||||
expect(data.messages.map((m: any) => m.text)).toContain('msg-200');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('sendMessage', () => {
|
||||
beforeEach(async () => {
|
||||
await channel.connect();
|
||||
});
|
||||
|
||||
it('pushes exact text to the buffer', async () => {
|
||||
await channel.sendMessage('emacs:default', 'response text');
|
||||
const { data } = await req(
|
||||
boundPort(channel),
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(data.messages[0].text).toBe('response text');
|
||||
});
|
||||
|
||||
it('attaches a numeric epoch-ms timestamp', async () => {
|
||||
const before = Date.now();
|
||||
await channel.sendMessage('emacs:default', 'ts-check');
|
||||
const after = Date.now();
|
||||
const { data } = await req(
|
||||
boundPort(channel),
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(data.messages[0].timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(data.messages[0].timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('authentication', () => {
|
||||
let authChannel: EmacsBridgeChannel;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
authChannel = new EmacsBridgeChannel(0, 'secret', opts);
|
||||
await authChannel.connect();
|
||||
port = boundPort(authChannel);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (authChannel.isConnected()) await authChannel.disconnect();
|
||||
});
|
||||
|
||||
it('rejects POST without Authorization header (401)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects POST with wrong token (401)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
{ Authorization: 'Bearer wrong' },
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts POST with correct Bearer token (200)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'POST',
|
||||
'/api/message',
|
||||
JSON.stringify({ text: 'hi' }),
|
||||
{ Authorization: 'Bearer secret' },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET without Authorization header (401)', async () => {
|
||||
const { status } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts GET with correct Bearer token (200)', async () => {
|
||||
const { status } = await req(
|
||||
port,
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
undefined,
|
||||
{ Authorization: 'Bearer secret' },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('channel without authToken ignores Authorization header entirely', async () => {
|
||||
const noAuthChannel = new EmacsBridgeChannel(0, null, opts);
|
||||
await noAuthChannel.connect();
|
||||
const noAuthPort = boundPort(noAuthChannel);
|
||||
try {
|
||||
const { status } = await req(
|
||||
noAuthPort,
|
||||
'GET',
|
||||
'/api/messages?since=0',
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
} finally {
|
||||
await noAuthChannel.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nanoclaw--md-to-org-regex (Emacs Lisp, tested via emacs --batch)
|
||||
|
||||
function emacsAvailable(): boolean {
|
||||
try {
|
||||
execSync('emacs --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mdToOrg(input: string): string {
|
||||
const elFile = path.resolve('emacs/nanoclaw.el');
|
||||
// Escape input as an Emacs string literal — no shell involved so no shell quoting needed
|
||||
const escaped = input
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n');
|
||||
// execFileSync passes args as an array (no shell), bypassing both shell quoting
|
||||
// and the vi.mock('fs') stub that would block writeFileSync
|
||||
return execFileSync(
|
||||
'emacs',
|
||||
[
|
||||
'--batch',
|
||||
'--load',
|
||||
elFile,
|
||||
'--eval',
|
||||
`(princ (nanoclaw--md-to-org-regex "${escaped}"))`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
}
|
||||
|
||||
describe.skipIf(!emacsAvailable())('nanoclaw--md-to-org-regex', () => {
|
||||
it('converts bold **text** → *text*', () => {
|
||||
expect(mdToOrg('**hello**')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('converts italic *text* → /text/', () => {
|
||||
expect(mdToOrg('*hello*')).toBe('/hello/');
|
||||
});
|
||||
|
||||
it('handles bold before italic in the same string', () => {
|
||||
expect(mdToOrg('**bold** and *italic*')).toBe('*bold* and /italic/');
|
||||
});
|
||||
|
||||
it('converts strikethrough ~~text~~ → +text+', () => {
|
||||
expect(mdToOrg('~~gone~~')).toBe('+gone+');
|
||||
});
|
||||
|
||||
it('converts underline __text__ → _text_', () => {
|
||||
expect(mdToOrg('__under__')).toBe('_under_');
|
||||
});
|
||||
|
||||
it('converts inline code `code` → ~code~', () => {
|
||||
expect(mdToOrg('`foo()`')).toBe('~foo()~');
|
||||
});
|
||||
|
||||
it('converts fenced code block with language', () => {
|
||||
expect(mdToOrg('```typescript\nconst x = 1;\n```')).toBe(
|
||||
'#+begin_src typescript\nconst x = 1;\n#+end_src',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts fenced code block without language', () => {
|
||||
expect(mdToOrg('```\nhello\n```')).toBe(
|
||||
'#+begin_src text\nhello\n#+end_src',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts ## heading → ** heading', () => {
|
||||
expect(mdToOrg('## Section')).toBe('** Section');
|
||||
});
|
||||
|
||||
it('converts ### heading → *** heading', () => {
|
||||
expect(mdToOrg('### Deep')).toBe('*** Deep');
|
||||
});
|
||||
|
||||
it('leaves list items unchanged', () => {
|
||||
expect(mdToOrg('- item one')).toBe('- item one');
|
||||
});
|
||||
|
||||
it('converts links [text](url) → [[url][text]]', () => {
|
||||
expect(mdToOrg('[NanoClaw](https://example.com)')).toBe(
|
||||
'[[https://example.com][NanoClaw]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../config.js';
|
||||
import { setRegisteredGroup } from '../db.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Channel, RegisteredGroup } from '../types.js';
|
||||
import { ChannelOpts, registerChannel } from './registry.js';
|
||||
|
||||
const EMACS_JID = 'emacs:default';
|
||||
|
||||
interface BufferedMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class EmacsBridgeChannel implements Channel {
|
||||
name = 'emacs';
|
||||
|
||||
private server: http.Server | null = null;
|
||||
private port: number;
|
||||
private authToken: string | null;
|
||||
private opts: ChannelOpts;
|
||||
private buffer: BufferedMessage[] = [];
|
||||
|
||||
constructor(port: number, authToken: string | null, opts: ChannelOpts) {
|
||||
this.port = port;
|
||||
this.authToken = authToken;
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.ensureGroupRegistered();
|
||||
this.ensureSymlink();
|
||||
this.ensureClaudeMd();
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (!this.checkAuth(req, res)) return;
|
||||
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${this.port}`);
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/message') {
|
||||
this.handlePost(req, res);
|
||||
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
|
||||
this.handlePoll(url, res);
|
||||
} else {
|
||||
res.writeHead(404).end(JSON.stringify({ error: 'Not found' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.listen(this.port, '127.0.0.1', () => {
|
||||
logger.info(
|
||||
{ port: this.port },
|
||||
'Emacs channel listening — load emacs/nanoclaw.el to connect',
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
this.server!.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
||||
this.server = null;
|
||||
logger.info('Emacs channel stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(_jid: string, text: string): Promise<void> {
|
||||
this.buffer.push({ text, timestamp: Date.now() });
|
||||
// Keep buffer bounded — 200 messages max
|
||||
if (this.buffer.length > 200) this.buffer.shift();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.server?.listening ?? false;
|
||||
}
|
||||
|
||||
ownsJid(jid: string): boolean {
|
||||
return jid === EMACS_JID;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private checkAuth(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): boolean {
|
||||
if (!this.authToken) return true;
|
||||
const header = req.headers['authorization'] ?? '';
|
||||
if (header === `Bearer ${this.authToken}`) return true;
|
||||
res.writeHead(401).end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
private handlePost(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { text } = JSON.parse(body) as { text?: string };
|
||||
if (!text?.trim()) {
|
||||
res.writeHead(400).end(JSON.stringify({ error: 'text required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const msgId = `emacs-${Date.now()}`;
|
||||
|
||||
this.opts.onChatMetadata(EMACS_JID, timestamp, 'Emacs', 'emacs', false);
|
||||
this.opts.onMessage(EMACS_JID, {
|
||||
id: msgId,
|
||||
chat_jid: EMACS_JID,
|
||||
sender: 'emacs',
|
||||
sender_name: 'Emacs',
|
||||
content: text,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
});
|
||||
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messageId: msgId, timestamp: Date.now() }));
|
||||
|
||||
logger.info({ length: text.length }, 'Emacs message received');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Emacs channel: failed to parse POST body');
|
||||
res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handlePoll(url: URL, res: http.ServerResponse): void {
|
||||
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
|
||||
const messages = this.buffer.filter((m) => m.timestamp > since);
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messages }));
|
||||
}
|
||||
|
||||
private ensureClaudeMd(): void {
|
||||
const claudeMd = path.join(GROUPS_DIR, 'emacs', 'CLAUDE.md');
|
||||
// groups/emacs symlinks to the main group folder on typical installs, so
|
||||
// this is a no-op when that CLAUDE.md already exists. On a fresh setup it
|
||||
// bootstraps the file so the agent knows to output markdown, not org-mode.
|
||||
if (fs.existsSync(claudeMd)) return;
|
||||
const content = [
|
||||
'## Message Formatting',
|
||||
'',
|
||||
'This is an Emacs channel. Responses are automatically converted from markdown',
|
||||
'to org-mode by the bridge before display.',
|
||||
'',
|
||||
'**Always format responses in standard markdown:**',
|
||||
'- `**bold**` not `*bold*`',
|
||||
'- `*italic*` not `/italic/`',
|
||||
'- `~~strikethrough~~` not `+strikethrough+`',
|
||||
'- `` `code` `` not `~code~`',
|
||||
'- ` ```lang ` fenced code blocks',
|
||||
'- `- ` for bullet points',
|
||||
'',
|
||||
'Do NOT output org-mode syntax directly. The bridge handles conversion.',
|
||||
'',
|
||||
].join('\n');
|
||||
try {
|
||||
fs.writeFileSync(claudeMd, content, 'utf8');
|
||||
logger.info('Emacs channel: wrote CLAUDE.md');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Emacs channel: could not write CLAUDE.md');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureGroupRegistered(): void {
|
||||
const groups = this.opts.registeredGroups();
|
||||
if (groups[EMACS_JID]) return;
|
||||
|
||||
const newGroup: RegisteredGroup = {
|
||||
name: 'emacs',
|
||||
folder: 'emacs',
|
||||
trigger: '',
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
};
|
||||
|
||||
try {
|
||||
setRegisteredGroup(EMACS_JID, newGroup);
|
||||
// Mutate the live cache so the message loop sees it immediately
|
||||
groups[EMACS_JID] = newGroup;
|
||||
logger.info('Emacs group auto-registered');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Emacs channel: failed to auto-register group');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSymlink(): void {
|
||||
const emacsDir = path.join(GROUPS_DIR, 'emacs');
|
||||
|
||||
// Find the main group's folder name
|
||||
const groups = this.opts.registeredGroups();
|
||||
const mainGroup = Object.values(groups).find((g) => g.isMain);
|
||||
const targetFolder = mainGroup?.folder ?? 'main';
|
||||
const targetDir = path.join(GROUPS_DIR, targetFolder);
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(emacsDir);
|
||||
if (stat.isSymbolicLink()) return; // already set up
|
||||
// Exists as a real directory — leave it alone
|
||||
logger.debug(
|
||||
{ emacsDir },
|
||||
'Emacs groups dir already exists as a directory',
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Does not exist — create it
|
||||
}
|
||||
|
||||
// Ensure the target exists before symlinking
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
fs.symlinkSync(targetDir, emacsDir);
|
||||
logger.info({ target: targetDir }, 'Created groups/emacs symlink');
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
'Emacs channel: failed to create groups/emacs symlink',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerChannel('emacs', (opts: ChannelOpts) => {
|
||||
const envVars = readEnvFile(['EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN']);
|
||||
const portStr =
|
||||
process.env.EMACS_CHANNEL_PORT || envVars.EMACS_CHANNEL_PORT || '8766';
|
||||
const port = parseInt(portStr, 10);
|
||||
const authToken =
|
||||
process.env.EMACS_AUTH_TOKEN || envVars.EMACS_AUTH_TOKEN || null;
|
||||
|
||||
return new EmacsBridgeChannel(port, authToken, opts);
|
||||
});
|
||||
@@ -10,6 +10,3 @@
|
||||
// telegram
|
||||
|
||||
// whatsapp
|
||||
|
||||
// emacs
|
||||
import './emacs.js';
|
||||
|
||||
@@ -561,6 +561,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')
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
deleteSession,
|
||||
getAllTasks,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
@@ -402,6 +403,24 @@ 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',
|
||||
|
||||
Reference in New Issue
Block a user