Merge branch 'main' into skill/signal

This commit is contained in:
gavrielc
2026-04-23 22:36:17 +03:00
committed by GitHub
5 changed files with 120 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "2.0.7", "version": "2.0.8",
"description": "Personal Claude assistant. Lightweight, secure, customizable.", "description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="129k tokens, 64% of context window">
<title>128k tokens, 64% of context window</title> <title>129k tokens, 64% of context window</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" 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"> <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 aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text> <text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text> <text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">129k</text>
<text x="71" y="14">128k</text> <text x="71" y="14">129k</text>
</g> </g>
</g> </g>
</a> </a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -17,58 +19,63 @@ describe('environment detection', () => {
}); });
}); });
describe('registered groups DB query', () => { describe('detectRegisteredGroups', () => {
let db: Database.Database; let tempDir: string;
beforeEach(() => { beforeEach(() => {
db = new Database(':memory:'); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
}); });
it('returns 0 for empty table', () => { afterEach(() => {
const row = db fs.rmSync(tempDir, { recursive: true, force: true });
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(0);
}); });
it('returns correct count after inserts', () => { it('returns false when no registration state exists', async () => {
db.prepare( const { detectRegisteredGroups } = await import('./environment.js');
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) expect(detectRegisteredGroups(tempDir)).toBe(false);
VALUES (?, ?, ?, ?, ?, ?)`, });
).run(
'123@g.us',
'Group 1',
'group-1',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
db.prepare( it('detects pre-migration registered_groups.json', async () => {
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) const { detectRegisteredGroups } = await import('./environment.js');
VALUES (?, ?, ?, ?, ?, ?)`, fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
).run( expect(detectRegisteredGroups(tempDir)).toBe(true);
'456@g.us', });
'Group 2',
'group-2',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
const row = db it('returns false for an empty v2 central DB', async () => {
.prepare('SELECT COUNT(*) as count FROM registered_groups') const { detectRegisteredGroups } = await import('./environment.js');
.get() as { count: number }; const db = new Database(path.join(tempDir, 'data', 'v2.db'));
expect(row.count).toBe(2); db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
it('detects wired agent groups in the v2 central DB', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
db.prepare(
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
).run('mga-1', 'mg-1', 'ag-1');
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(true);
}); });
}); });

View File

@@ -7,11 +7,35 @@ import path from 'path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js'; import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true;
}
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return false;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
)
.get() as { count: number };
return row.count > 0;
} catch {
return false;
} finally {
db?.close();
}
}
export async function run(_args: string[]): Promise<void> { export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd(); const projectRoot = process.cwd();
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
const authDir = path.join(projectRoot, 'store', 'auth'); const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false; const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
// Check for existing OpenClaw installation // Check for existing OpenClaw installation
const homedir = (await import('os')).homedir(); const homedir = (await import('os')).homedir();

View File

@@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig {
* chunk boundary will render as two independent blocks on the receiving * chunk boundary will render as two independent blocks on the receiving
* platform, which is the same behavior as manually re-opening a fence. * platform, which is the same behavior as manually re-opening a fence.
*/ */
/**
* Decode the actual option value from a button callback. Buttons are encoded
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
* and the real value is looked up via `getAskQuestionRender(questionId)`.
* Falls back to treating the tail as a literal value so old in-flight cards
* (encoded before this shortening landed) still resolve.
*/
function resolveSelectedOption(
render: { options: NormalizedOption[] } | undefined,
eventValue: string | undefined,
tail: string | undefined,
): string {
const candidate = eventValue ?? tail ?? '';
if (render && /^\d+$/.test(candidate)) {
const idx = Number(candidate);
if (render.options[idx]) return render.options[idx].value;
}
return candidate;
}
export function splitForLimit(text: string, limit: number): string[] { export function splitForLimit(text: string, limit: number): string[] {
if (text.length <= limit) return [text]; if (text.length <= limit) return [text];
const chunks: string[] = []; const chunks: string[] = [];
@@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
const parts = event.actionId.split(':'); const parts = event.actionId.split(':');
if (parts.length < 3) return; if (parts.length < 3) return;
const questionId = parts[1]; const questionId = parts[1];
const selectedOption = event.value || ''; const tail = parts.slice(2).join(':');
const userId = event.user?.userId || ''; const userId = event.user?.userId || '';
// Resolve render metadata BEFORE dispatching onAction (which deletes the row). // Resolve render metadata BEFORE dispatching onAction (which deletes the row).
const render = getAskQuestionRender(questionId); const render = getAskQuestionRender(questionId);
// New format: button id/value is an integer index into options (kept
// short to fit Telegram's 64-byte callback_data cap). Old format:
// the full value is embedded in actionId/value directly.
const selectedOption = resolveSelectedOption(render, event.value, tail);
const title = render?.title ?? '❓ Question'; const title = render?.title ?? '❓ Question';
const matched = render?.options.find((o) => o.value === selectedOption); const matched = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
@@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
children: [ children: [
CardText(question), CardText(question),
Actions( Actions(
options.map((opt) => // Encode button id/value with the option index rather than the
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), // full value. Telegram caps callback_data at 64 bytes, and
// long values (e.g. ISO datetimes, URLs) push the JSON payload
// well past that. The onAction handlers resolve the index back
// to the real value via getAskQuestionRender(questionId).
options.map((opt, idx) =>
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
), ),
), ),
], ],
@@ -507,12 +536,12 @@ async function handleForwardedEvent(
// Parse the selected option from custom_id // Parse the selected option from custom_id
let questionId: string | undefined; let questionId: string | undefined;
let selectedOption: string | undefined; let tail: string | undefined;
if (customId?.startsWith('ncq:')) { if (customId?.startsWith('ncq:')) {
const colonIdx = customId.indexOf(':', 4); // after "ncq:" const colonIdx = customId.indexOf(':', 4); // after "ncq:"
if (colonIdx !== -1) { if (colonIdx !== -1) {
questionId = customId.slice(4, colonIdx); questionId = customId.slice(4, colonIdx);
selectedOption = customId.slice(colonIdx + 1); tail = customId.slice(colonIdx + 1);
} }
} }
@@ -521,6 +550,9 @@ async function handleForwardedEvent(
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || []; ((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
const originalDescription = (originalEmbeds[0]?.description as string) || ''; const originalDescription = (originalEmbeds[0]?.description as string) || '';
const render = questionId ? getAskQuestionRender(questionId) : undefined; const render = questionId ? getAskQuestionRender(questionId) : undefined;
// Discord custom_id mirrors the new index-based encoding (see Button
// construction). Decode back to the real option value for downstream.
const selectedOption = resolveSelectedOption(render, tail, tail);
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
const matchedOpt = render?.options.find((o) => o.value === selectedOption); const matchedOpt = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;