From e370cae4e76ac188752ec62d56ef39754fa3f77b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 22:31:31 +0300 Subject: [PATCH] PR11: migrate skills to ncl-based wiring and channel-defaults docs - Replace raw wiring/mg SQL with ncl invocations across eight skill artifacts (add-wechat wire-dm.ts rewritten to shell out to ncl; add-github/add-linear/add-signal/add-deltachat/add-emacs/migrate-from-openclaw snippets), each noting the running-host prerequisite and fixing the invalid 'isolated' session mode - add-whatsapp: new "Dedicated vs personal number" section (ASSISTANT_HAS_OWN_NUMBER semantics, absent=shared inference), update path for flag-unset installs, spam-era group-wiring migration audit, stale pending_channel_approvals cleanup, stale-adapter skew troubleshooting - manage-channels: two-level defaults model, --threads override + sticky/threads coercion, mention-capability constraints and {name}-pattern rename caveat, legacy WhatsApp group check - update-nanoclaw: release notes for the two adapter-update behavior changes (Slack/Discord DM replies top-level, shared-identity stranger cards gone) + /add-whatsapp re-run pointer Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ --- .claude/skills/add-deltachat/SKILL.md | 11 +- .claude/skills/add-emacs/SKILL.md | 8 + .claude/skills/add-github/SKILL.md | 29 ++-- .claude/skills/add-linear/SKILL.md | 19 +-- .claude/skills/add-signal/SKILL.md | 25 +-- .claude/skills/add-wechat/SKILL.md | 18 ++- .claude/skills/add-wechat/scripts/wire-dm.ts | 153 ++++++++++-------- .claude/skills/add-whatsapp/SKILL.md | 59 ++++++- .claude/skills/manage-channels/SKILL.md | 55 ++++++- .claude/skills/migrate-from-openclaw/SKILL.md | 17 +- .claude/skills/update-nanoclaw/SKILL.md | 26 +++ 11 files changed, 299 insertions(+), 121 deletions(-) diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md index 32b8927d0..a4c823482 100644 --- a/.claude/skills/add-deltachat/SKILL.md +++ b/.claude/skills/add-deltachat/SKILL.md @@ -167,7 +167,14 @@ pnpm exec tsx scripts/init-first-agent.ts \ ### Groups -Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group. +Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group, or wire it directly with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket): + +```bash +# Engage mode/pattern default to the DeltaChat adapter's declared channel +# defaults — for DeltaChat groups that's a name pattern (the platform has no +# mention metadata), so the agent responds when addressed by name. +ncl wirings create --messaging-group-id --agent-group-id +``` ## Next Steps @@ -184,7 +191,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaC - **user-id-format**: `deltachat:{email}` — the contact's email address - **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above - **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat -- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode +- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session) ### Features diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 3e221d961..c646ae06d 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -105,6 +105,14 @@ pnpm exec tsx setup/index.ts --step register -- \ `agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent. +Alternatively create the rows with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket). Engage mode/pattern and `unknown_sender_policy` default to the Emacs adapter's declared channel defaults: + +```bash +ncl messaging-groups create --channel-type emacs --platform-id "default" --name "Emacs" +ncl wirings create --messaging-group-id --agent-group-id \ + --session-mode agent-shared +``` + ## Configure Emacs `nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager. diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md index a6e1c2656..68164d167 100644 --- a/.claude/skills/add-github/SKILL.md +++ b/.claude/skills/add-github/SKILL.md @@ -107,16 +107,17 @@ Ask the user: **Is this a private or public repo?** - **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through. - **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below). -Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually: +Run `/manage-channels` to wire the GitHub channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket: -```sql --- Create messaging group (one per repo) -INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at) -VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '', strftime('%Y-%m-%dT%H:%M:%fZ','now')); +```bash +# Create messaging group (one per repo) +ncl messaging-groups create --channel-type github --platform-id "github:owner/repo" \ + --name "owner/repo" --is-group 1 --unknown-sender-policy --- Wire to agent group -INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) -VALUES ('mga-github-myrepo', 'mg-github-myrepo', '', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +# Wire to agent group (engage mode/pattern default to the GitHub adapter's +# declared channel defaults; grab the mg id from the create output above) +ncl wirings create --messaging-group-id --agent-group-id \ + --session-mode per-thread ``` Replace `` with `public` or `strict` based on the user's choice above. @@ -125,14 +126,12 @@ Replace `` with `public` or `strict` based on the user's choice above. When using `strict`, add each GitHub user who should be able to trigger the agent: -```sql --- Add user (kind = 'github', id = 'github:') -INSERT OR IGNORE INTO users (id, kind, display_name, created_at) -VALUES ('github:', 'github', '', strftime('%Y-%m-%dT%H:%M:%fZ','now')); +```bash +# Add user (kind = 'github', id = 'github:') +ncl users create --id "github:" --kind github --display-name "" --- Grant membership to the agent group -INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id) -VALUES ('github:', ''); +# Grant membership to the agent group +ncl members add --user "github:" --group "" ``` To find a GitHub user's numeric ID: `gh api users/ --jq .id` diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md index 1af266eb5..94d39cd50 100644 --- a/.claude/skills/add-linear/SKILL.md +++ b/.claude/skills/add-linear/SKILL.md @@ -115,19 +115,20 @@ Ask the user: **Is this a private or public Linear workspace?** - **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment. - **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example). -Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually: +Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket: -```sql --- Create messaging group (one per team) -INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at) -VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now')); +```bash +# Create messaging group (one per team) +ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \ + --name "Engineering" --is-group 1 --unknown-sender-policy --- Wire to agent group -INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) -VALUES ('mga-linear-eng', 'mg-linear-eng', '', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +# Wire to agent group (engage mode/pattern default to the Linear adapter's +# declared channel defaults; grab the mg id from the create output above) +ncl wirings create --messaging-group-id --agent-group-id \ + --session-mode per-thread ``` -The `platform_id` must be `linear:` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session. +Replace `` with `public` or `strict` based on the user's choice above. The `platform_id` must be `linear:` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session. ## Next Steps diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 1f48e3a89..514a8f506 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -220,30 +220,21 @@ Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agen ### Groups -Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions: +Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket): ```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -pnpm exec tsx scripts/q.ts data/v2.db " -INSERT OR IGNORE INTO messaging_group_agents - (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) -VALUES - ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); -" +# Engage mode/pattern default to the Signal adapter's declared channel defaults +ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID ``` ### Grant user access -New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`: +New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups` (host service running): ```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -pnpm exec tsx scripts/q.ts data/v2.db " -INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) - VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); -INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) - VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); -" +ncl users create --id "signal:UUID" --kind signal --display-name "" +ncl roles grant --user "signal:UUID" --role owner +ncl members add --user "signal:UUID" --group ag-AGENTID ``` Find the UUID from `messaging_groups.platform_id` or the `users` table. @@ -264,7 +255,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal - Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID - **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above - **typical-use**: Personal assistant via Signal DMs or small group chats -- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session) ### Features diff --git a/.claude/skills/add-wechat/SKILL.md b/.claude/skills/add-wechat/SKILL.md index 80a29a5da..ce228d259 100644 --- a/.claude/skills/add-wechat/SKILL.md +++ b/.claude/skills/add-wechat/SKILL.md @@ -121,9 +121,11 @@ The bot is now connected as your WeChat account. A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond. +**Prerequisite: the host service must be running.** The wire script creates the wiring through `ncl`, which talks to the running host over a Unix socket — there is no offline mode. + ### 1. Trigger the first inbound message -Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`. +Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id` and the `unknown_sender_policy` the WeChat adapter declares. ### 2. Run the wire script @@ -131,9 +133,9 @@ Have a different WeChat account send a message to the bot account. This auto-cre pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts ``` -Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`). +Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and runs `ncl wirings create` — engage mode/pattern and priority come from the WeChat adapter's declared channel defaults, so a wiring created here matches one created by `/manage-channels` or the approval-card flow. -With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent. +With `request_approval` as the sender policy, the next DM from a stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent. Non-interactive: @@ -147,10 +149,16 @@ pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \ Flags: - `--platform-id ` — wire a specific messaging group (default: most recent unwired) -- `--agent-group ` — target agent group (default: prompt; or solo admin group in non-interactive) -- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs) +- `--agent-group ` — target agent group (default: prompt; auto-picked when only one exists) +- `--sender-policy public|strict|request_approval` — override the messaging group's `unknown_sender_policy` (default: leave whatever the WeChat adapter declared when the row was auto-created) - `--session-mode shared|per-thread` — default `shared` +Equivalent raw `ncl` invocation (host must be running): + +```bash +ncl wirings create --messaging-group-id --agent-group-id --session-mode shared +``` + ### 3. Test Have the sender message the bot again — the agent should respond. diff --git a/.claude/skills/add-wechat/scripts/wire-dm.ts b/.claude/skills/add-wechat/scripts/wire-dm.ts index 68c8cb4ca..de653e903 100644 --- a/.claude/skills/add-wechat/scripts/wire-dm.ts +++ b/.claude/skills/add-wechat/scripts/wire-dm.ts @@ -5,43 +5,49 @@ * After /add-wechat installs the adapter and the user scans the QR login, * the first inbound message from another WeChat account auto-creates a * `messaging_groups` row. This script finds that row, asks the operator - * which agent group to wire it to, and inserts the `messaging_group_agents` - * join row with sensible defaults — the "post-login wiring" step /add-wechat - * otherwise requires manual SQL for. + * which agent group to wire it to, and creates the wiring via + * `ncl wirings create` — engage mode/pattern and priority come from the + * WeChat adapter's declared channel defaults, not from SQL baked into this + * script, so it can't drift against schema migrations. * - * Usage: + * PREREQUISITE: the NanoClaw host service must be RUNNING — `ncl` talks to + * it over a Unix socket and has no offline mode. + * + * Usage (from the project root): * pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts * * Flags: * --platform-id Wire a specific messaging group (default: most recent unwired) - * --agent-group Target agent group (default: interactive pick; or solo admin group) - * --sender-policy

public | strict (default: public) + * --agent-group Target agent group (default: interactive pick; auto-picked when only one exists) + * --sender-policy

public | strict | request_approval — overrides the + * channel-declared unknown_sender_policy on the + * messaging group (default: leave as the adapter declared) * --session-mode shared | per-thread (default: shared) * --non-interactive Fail instead of prompting */ -import Database from 'better-sqlite3'; +import { spawnSync } from 'node:child_process'; import path from 'node:path'; import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; -const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db'); +// /.claude/skills/add-wechat/scripts/wire-dm.ts → +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); type SenderPolicy = 'public' | 'strict' | 'request_approval'; interface Args { platformId?: string; agentGroupId?: string; - senderPolicy: SenderPolicy; + senderPolicy?: SenderPolicy; sessionMode: 'shared' | 'per-thread'; interactive: boolean; } function parseArgs(argv: string[]): Args { const args: Args = { - // Default matches the router's auto-create (`request_approval`) so the - // admin gets an approval card on the next unknown-sender DM rather than - // a silent allow. Pass `--sender-policy public` to open the channel to - // anyone, or `strict` to require explicit membership. - senderPolicy: 'request_approval', + // No --sender-policy default: the router already stamped the policy the + // WeChat adapter declares when it auto-created the messaging group. + // Only an explicit flag overrides it. sessionMode: 'shared', interactive: true, }; @@ -68,72 +74,90 @@ function parseArgs(argv: string[]): Args { return args; } +/** Run one ncl command against the running host and return its parsed data. */ +function ncl(...cliArgs: string[]): unknown { + const res = spawnSync('pnpm', ['exec', 'tsx', 'src/cli/client.ts', ...cliArgs, '--json'], { + cwd: PROJECT_ROOT, + encoding: 'utf-8', + }); + if (res.error) throw res.error; + let frame: { ok: boolean; data?: unknown; error?: { message: string } } | undefined; + try { + frame = JSON.parse(res.stdout); + } catch { + // No frame — transport-level failure (host not running), reported on stderr. + } + if (frame && !frame.ok) throw new Error(`ncl ${cliArgs.join(' ')} failed: ${frame.error?.message}`); + if (!frame || res.status !== 0) { + const detail = (res.stderr || res.stdout || '').trim(); + throw new Error( + `ncl ${cliArgs.join(' ')} failed:\n${detail}\n\n` + + 'Is the NanoClaw host service running? ncl connects to it over a Unix socket.', + ); + } + return frame.data; +} + async function prompt(q: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); })); } -function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} +interface MgRow { id: string; platform_id: string; name: string | null; is_group: number; created_at: string } +interface AgRow { id: string; name: string; created_at: string } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); - const db = new Database(DB_PATH); - db.pragma('journal_mode = WAL'); + + const mgs = ncl('messaging-groups', 'list', '--channel-type', 'wechat') as MgRow[]; + const wirings = ncl('wirings', 'list', '--limit', '10000') as Array<{ messaging_group_id: string }>; + const wiredMgIds = new Set(wirings.map((w) => w.messaging_group_id)); // 1. Pick the messaging group - let platformId = args.platformId; - if (!platformId) { - const rows = db.prepare(` - SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at - FROM messaging_groups mg - LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id - WHERE mg.channel_type = 'wechat' AND mga.id IS NULL - ORDER BY mg.created_at DESC - `).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>; + let mg: MgRow | undefined; + if (args.platformId) { + mg = mgs.find((r) => r.platform_id === args.platformId); + if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${args.platformId}`); + } else { + const unwired = mgs + .filter((r) => !wiredMgIds.has(r.id)) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); - if (rows.length === 0) { + if (unwired.length === 0) { console.error('No unwired WeChat messaging groups found.'); console.error('Send a message to the bot first (from another WeChat account), then re-run.'); process.exit(1); } - if (rows.length === 1 || !args.interactive) { - platformId = rows[0].platform_id; - console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`); + if (unwired.length === 1 || !args.interactive) { + mg = unwired[0]; + console.log(`Using most recent unwired group: ${mg.platform_id} (${mg.is_group ? 'group' : 'DM'})`); } else { console.log('Unwired WeChat messaging groups:'); - rows.forEach((r, i) => { + unwired.forEach((r, i) => { console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`); }); const pick = await prompt('Pick one [1]: '); const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; - if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice'); - platformId = rows[idx].platform_id; + if (Number.isNaN(idx) || idx < 0 || idx >= unwired.length) throw new Error('invalid choice'); + mg = unwired[idx]; } } - const mg = db.prepare( - 'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?' - ).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined; - if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`); - // 2. Pick the agent group let agentGroupId = args.agentGroupId; if (!agentGroupId) { - const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC') - .all() as Array<{ id: string; name: string; is_admin: number }>; + const agents = (ncl('groups', 'list') as AgRow[]) + .sort((a, b) => a.created_at.localeCompare(b.created_at)); if (agents.length === 0) throw new Error('no agent groups exist — create one first'); - const adminAgents = agents.filter((a) => a.is_admin === 1); - if (adminAgents.length === 1 && !args.interactive) { - agentGroupId = adminAgents[0].id; - console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`); + if (agents.length === 1) { + agentGroupId = agents[0].id; + console.log(`Auto-selected sole agent group: ${agents[0].name} (${agentGroupId})`); } else if (args.interactive) { console.log('Agent groups:'); agents.forEach((a, i) => { - console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`); + console.log(` ${i + 1}. ${a.name} (${a.id})`); }); const pick = await prompt('Pick one [1]: '); const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; @@ -144,26 +168,29 @@ async function main(): Promise { } } - const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as - { id: string; name: string } | undefined; + const ag = (ncl('groups', 'list') as AgRow[]).find((a) => a.id === agentGroupId); if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`); - // 3. Update sender policy + wire - const tx = db.transaction(() => { - db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?') - .run(args.senderPolicy, mg.id); - - db.prepare(` - INSERT INTO messaging_group_agents - (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) - VALUES (?, ?, ?, '', 'all', ?, 10, ?) - `).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString()); - }); - tx(); + // 3. Wire, then apply the optional policy override. Engage mode/pattern and + // priority are filled by the wirings resolveDefaults hook from the WeChat + // adapter's declared channel defaults. Policy update runs second so a + // failed create (e.g. already wired) leaves the mg row untouched. + const wiring = ncl( + 'wirings', 'create', + '--messaging-group-id', mg.id, + '--agent-group-id', ag.id, + '--session-mode', args.sessionMode, + ) as { engage_mode: string; engage_pattern: string | null }; + if (args.senderPolicy) { + ncl('messaging-groups', 'update', mg.id, '--unknown-sender-policy', args.senderPolicy); + } console.log(''); - console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`); - db.close(); + console.log( + `WIRED platform_id=${mg.platform_id} agent_group=${ag.name} ` + + `engage=${wiring.engage_mode}${wiring.engage_pattern ? `(${wiring.engage_pattern})` : ''} ` + + `policy=${args.senderPolicy ?? '(channel default)'} mode=${args.sessionMode}`, + ); } main().catch((err) => { diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 81b1b76a5..e008af637 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -92,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi ### Check current state -Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number". +Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number". ```bash test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth" @@ -202,16 +202,60 @@ for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed" ``` -### Shared vs dedicated number +## Dedicated vs personal number + +The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human. + +- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name. +- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound. AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number? - **Shared number** — your personal WhatsApp (bot prefixes messages with its name) - **Dedicated number** — a separate phone/SIM for the assistant -If dedicated, add to `.env`: +Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs): ```bash +# Dedicated: ASSISTANT_HAS_OWN_NUMBER=true +# Shared/personal: +ASSISTANT_HAS_OWN_NUMBER=false +``` + +### Update path: existing install, flag unset + +If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly. + +Suggest a default by comparing the authed number against the wired DM chat: + +```bash +# The number this install is authenticated as +node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0])" +# The wired WhatsApp DM chats +pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=0" +``` + +If the wired DM's phone **equals** the authed number, the operator is talking to the bot in their own self-chat — that's a personal number: suggest **Shared**. If they differ, the operator messages the bot from a different number: suggest **Dedicated**. Confirm with the operator either way, then write the flag and restart the service. + +### Migration audit: spam-era group wirings + +Before the shared-number fix, group chats approved via the channel-registration card were wired `engage_mode='pattern'` with pattern `.` — respond-to-everything — because the card flow couldn't tell groups from DMs on non-threaded platforms. On a personal number this shows up as the bot answering every message in family/work groups after someone once tapped Connect on a spam-triggered card. + +List the suspect wirings (host service running — `ncl` is socket-only): + +```bash +ncl wirings list --engage-mode pattern --engage-pattern "." --json +``` + +Cross-reference against WhatsApp group chats (`ncl messaging-groups list --channel-type whatsapp --is-group 1`). For each wiring with pattern `.` on a WhatsApp group that is *not* the operator's deliberate always-on chat (e.g. their self-chat), offer: + +- **Flip to name-based engagement**: `ncl wirings update --engage-mode pattern --engage-pattern '\b\b'` (or `--engage-mode mention` on a dedicated number) +- **Delete the wiring**: `ncl wirings delete ` + +Stale approval cards from that era can also linger. Clear pending channel approvals for chats the operator doesn't want wired: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')" ``` ## Next Steps @@ -289,3 +333,12 @@ systemctl --user start $(systemd_unit) ### "conflict" disconnection Two instances connected with same credentials. Ensure only one NanoClaw process is running. + +### Trunk updated but shared-number behavior unchanged (stale adapter copy) + +The shared-number behavior (no stranger approval cards, name-pattern group defaults) lives in the **adapter copy** at `src/channels/whatsapp.ts`, installed from the `channels` branch — not in trunk. If you updated trunk via `/update-nanoclaw` but skipped the skill-update step, the old adapter copy neither reads `ASSISTANT_HAS_OWN_NUMBER` itself nor declares channel defaults, so trunk falls back to the legacy behavior: approval cards still fire on a personal number, and new wirings get the channel-blind defaults. Symptoms of the skew: + +- `.env` says `ASSISTANT_HAS_OWN_NUMBER=false` (or unset) but strangers' DMs still raise approval cards +- `ncl wirings create` on a WhatsApp group defaults to `mention` instead of a name pattern + +Fix: re-run `/add-whatsapp` (or `/update-skills`) to pull the current adapter from the `channels` branch, then restart the service. The reverse skew (new adapter, old trunk) can't happen — the adapter's `defaults` field is optional and old trunk ignores it. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 96b8374b2..add65fd32 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/q.ts data/v2.db "" ```sql SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups; -SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents; +SELECT messaging_group_id, agent_group_id, engage_mode, engage_pattern, session_mode, threads, priority FROM messaging_group_agents; SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC; ``` @@ -36,6 +36,47 @@ If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='o **Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels. +## Channel Defaults: The Two-Level Model + +Wiring defaults (engage mode/pattern, threading, `unknown_sender_policy`) resolve through **exactly two levels**: + +1. **Adapter declaration** — each channel adapter declares `ChannelDefaults` (separate DM and group contexts, plus a `mentions` capability) in its source file. The adapter copy is skill-installed and user-owned: to change a default install-wide, edit `src/channels/.ts` and restart. Declarations are never persisted to the DB. +2. **Per-wiring/per-mg values chosen at creation** — every creation surface (`ncl wirings create` / `ncl messaging-groups create`, the `register` wizard step, the approval-card flow, `/init-first-agent`) fills omitted fields from the declaration and stores the result on the row. Pass explicit flags to override one wiring. + +There is no third level: existing rows are never re-resolved, so editing a declaration only affects wirings created afterward. The one exception is the **`threads` column**, which stays live — `NULL` means "inherit the declaration at message time". + +Channels with no declaration (stale adapter copies) fall back to the legacy behavior; run `/update-skills` to pull current adapters. + +### Wiring via ncl + +`ncl` requires the **host service to be running** (it connects over a Unix socket): + +```bash +ncl messaging-groups create --channel-type --platform-id "" --name "" [--is-group 1] +ncl wirings create --messaging-group-id --agent-group-id [--session-mode ] +``` + +Omitted `engage_mode`/`engage_pattern`/`unknown_sender_policy` come from the adapter declaration for the right context (DM vs group). Run `ncl wirings help` / `ncl messaging-groups help` for the full flag list. + +### Threading override (`--threads`) + +`ncl wirings create/update ... --threads true|false` controls whether platform thread ids are honored for this wiring. `true` (in groups) means per-thread sessions and in-thread replies/typing/cards; `false` collapses to a flat session with top-level replies. Omitted = `NULL` = inherit the channel declaration. A wiring can *disable* threads on a threaded platform (Slack, Discord, GitHub), never enable them on a non-threaded one. + +Two consequences to warn the user about: + +- **Session identity**: sessions are never deleted. Flipping `threads` on a live wiring orphans existing per-thread sessions (or splinters a shared one) — history stays in the old sessions; new messages start fresh ones. +- **`mention-sticky` needs threads**: sticky engagement is keyed on per-thread session existence, so with resolved threads off it would engage once and never disengage. Creation and update coerce `mention-sticky` → `mention` (with a warning) when the effective thread policy is off. + +### Mention capability + +Each declaration states which mention signal the adapter emits: `platform` (real platform mentions), `dm-only` (only DMs are flagged), or `never`. On a `mentions: 'never'` channel (Linear OAuth apps, WhatsApp personal-number mode, Emacs), `mention`/`mention-sticky` wirings are **inert — they can never engage** — and `ncl` rejects them at create/update with an error citing the declaration. For groups on those channels, use a name pattern instead: + +```bash +ncl wirings update --engage-mode pattern --engage-pattern '(?i)^@?\b' +``` + +**Renaming an agent group does not update stored patterns.** Declared group patterns containing `{name}` are substituted with the agent group's name *at creation* and stored literally — after `ncl groups update --name `, audit that group's wirings for patterns still matching the old name and update them. + ## Wire New Channel For each unwired channel: @@ -67,6 +108,8 @@ pnpm exec tsx setup/index.ts --step register -- \ The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name. +Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger ""` (explicit engage pattern), `--engage-mode `, `--is-group `, `--unknown-sender-policy `. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there. + When creating a NEW agent group on a non-default provider, append `--provider ` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead. For separate agents, also ask for a folder name and optionally a different assistant name. @@ -94,6 +137,16 @@ When adding another group/chat on an already-configured platform (e.g. a second 3. Delete the old `messaging_group_agents` entry, create a new one 4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually. +## One-Time Check: Legacy Mis-Wired WhatsApp Groups + +Installs that approved WhatsApp group registration cards before the channel-defaults model wired those groups as `engage_mode='pattern'`, `engage_pattern='.'` — respond-to-everything (the card flow couldn't tell groups from DMs on non-threaded platforms). Check once: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db "SELECT mga.id, mg.platform_id, mg.name FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=1 AND mga.engage_mode='pattern' AND mga.engage_pattern='.'" +``` + +For any hit the operator didn't deliberately configure as always-on, offer the repair options in `/add-whatsapp`'s "Migration audit" section (flip to mention/name-pattern engagement, or delete the wiring). + ## Show Configuration Display a readable summary showing: diff --git a/.claude/skills/migrate-from-openclaw/SKILL.md b/.claude/skills/migrate-from-openclaw/SKILL.md index ec6cb165f..43697e965 100644 --- a/.claude/skills/migrate-from-openclaw/SKILL.md +++ b/.claude/skills/migrate-from-openclaw/SKILL.md @@ -194,8 +194,10 @@ Notes: runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id). - Reuse a `--folder` to put a group on an existing agent (shared base/separate conversations); use a new `--folder` for a fully separate agent. -- Group chats default to mention-only; pass `--trigger` to set a regex, or - `--no-trigger-required` for respond-to-everything. +- Engage defaults come from the channel adapter's declaration (most group + chats default to mention-based engagement; channels without a mention + signal default to a name pattern). Pass `--trigger` to set an explicit + regex, or `--no-trigger-required` for respond-to-everything. - Register groups from channels v2 doesn't support yet too — the messaging group and wiring persist and activate when that channel is installed. @@ -238,10 +240,13 @@ model, which is **not** a JSON file. Each messaging group has an - `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but unwired). -The messaging groups `register` / `init-first-agent` create already default to -`unknown_sender_policy = 'strict'`, so unknown senders are gated until you add -them. Show the user the OpenClaw allowlist and confirm who to grant before -running the commands. +The messaging groups `register` / `init-first-agent` create default their +`unknown_sender_policy` to whatever the channel adapter declares for that +context (DM vs group) — `strict` when the channel has no declaration — so +unknown senders are gated until you add them (or an admin approves the +adapter-declared approval card). Pass `--unknown-sender-policy` to `register` +to override. Show the user the OpenClaw allowlist and confirm who to grant +before running the commands. ## Phase 3: Identity and Memory diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 586fea2f8..bdd9647f2 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -281,6 +281,32 @@ Keep it to these two options — the per-skill selection lives inside its Step 4 — so nothing container-related is owed back here.) - On "Skip": note that `/update-skills` can be run anytime, then proceed. +## Known behavior changes when channel adapters update + +Channel adapters now declare per-channel wiring defaults (engage mode, threading, +sender policy). Updating trunk alone changes nothing for existing rows, but once +`/update-skills` pulls current adapter copies, two deliberate behavior changes +land. If the user's install has Slack, Discord, or WhatsApp, tell them: + +1. **Slack/Discord DM replies move top-level.** Both adapters now declare + `threads: false` for DMs, so DM replies stop chasing per-message sub-threads + and land in the main DM view, matching the DM session (which was already + flat). Group/channel threading is unchanged. To keep the old in-thread DM + behavior for a specific wiring, override it per wiring: + `ncl wirings update --threads true`. +2. **Shared-identity channels stop raising stranger approval cards.** On + channels where the linked account is the operator's personal identity + (WhatsApp personal-number mode, iMessage, WeChat), the adapter no longer + emits a mention signal — messages to that identity address the human, so + unknown senders no longer auto-create messaging groups or fire + channel-registration cards to the admin. + +**WhatsApp installs on a shared/personal number should re-run `/add-whatsapp`** +after the skill update: it now asks the dedicated-vs-personal question +explicitly (writing `ASSISTANT_HAS_OWN_NUMBER` to `.env`), audits for legacy +mis-wired group rows from spam-era approval cards, and shows how to clear +stale pending approvals. + Proceed to Step 7.9. # Step 7.9: Stamp the upgrade marker (required)