Merge branch 'main' into fix/claw-mounts

This commit is contained in:
gavrielc
2026-03-25 22:47:14 +02:00
committed by GitHub
28 changed files with 829 additions and 85 deletions

View File

@@ -64,10 +64,18 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const TRIGGER_PATTERN = new RegExp(
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
'i',
);
export function buildTriggerPattern(trigger: string): RegExp {
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
}
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
export function getTriggerPattern(trigger?: string): RegExp {
const normalizedTrigger = trigger?.trim();
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
}
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.

View File

@@ -42,6 +42,7 @@ export interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
export interface ContainerOutput {
@@ -191,8 +192,17 @@ function buildVolumeMounts(
group.folder,
'agent-runner-src',
);
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
if (fs.existsSync(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) &&
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
}
mounts.push({
hostPath: groupAgentRunnerDir,
@@ -667,6 +677,7 @@ export function writeTasksSnapshot(
id: string;
groupFolder: string;
prompt: string;
script?: string | null;
schedule_type: string;
schedule_value: string;
status: string;

View File

@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
// Add script column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
} catch {
/* column already exists */
}
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
try {
database.exec(
@@ -368,14 +375,15 @@ export function createTask(
): void {
db.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.group_folder,
task.chat_jid,
task.prompt,
task.script || null,
task.schedule_type,
task.schedule_value,
task.context_mode || 'isolated',
@@ -410,7 +418,12 @@ export function updateTask(
updates: Partial<
Pick<
ScheduledTask,
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'
| 'prompt'
| 'script'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
>
>,
): void {
@@ -421,6 +434,10 @@ export function updateTask(
fields.push('prompt = ?');
values.push(updates.prompt);
}
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?');
values.push(updates.schedule_type);

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
import {
ASSISTANT_NAME,
getTriggerPattern,
TRIGGER_PATTERN,
} from './config.js';
import {
escapeXml,
formatMessages,
@@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => {
});
});
describe('getTriggerPattern', () => {
it('uses the configured per-group trigger when provided', () => {
const pattern = getTriggerPattern('@Claw');
expect(pattern.test('@Claw hello')).toBe(true);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
});
it('falls back to the default trigger when group trigger is missing', () => {
const pattern = getTriggerPattern(undefined);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
});
it('treats regex characters in custom triggers literally', () => {
const pattern = getTriggerPattern('@C.L.A.U.D.E');
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
});
});
// --- Outbound formatting (internal tag stripping + prefix) ---
describe('stripInternalTags', () => {
@@ -207,7 +233,7 @@ describe('formatOutbound', () => {
describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
// if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger }
function shouldRequireTrigger(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
@@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => {
function shouldProcess(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
trigger: string | undefined,
messages: NewMessage[],
): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
const triggerPattern = getTriggerPattern(trigger);
return messages.some((m) => triggerPattern.test(m.content.trim()));
}
it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, msgs)).toBe(true);
expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
});
it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, msgs)).toBe(true);
expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, msgs)).toBe(false);
expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, msgs)).toBe(false);
expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, msgs)).toBe(true);
expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
});
it('non-main group uses its per-group trigger instead of the default trigger', () => {
const msgs = [makeMsg({ content: '@Claw do something' })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
});
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
});
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, msgs)).toBe(true);
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
});
});

View File

@@ -5,11 +5,13 @@ import { OneCLI } from '@onecli-sh/sdk';
import {
ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import './channels/index.js';
import {
@@ -133,6 +135,26 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
// Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(
GROUPS_DIR,
group.isMain ? 'main' : 'global',
'CLAUDE.md',
);
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
@@ -194,10 +216,11 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(m) =>
TRIGGER_PATTERN.test(m.content.trim()) &&
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
@@ -376,7 +399,7 @@ async function startMessageLoop(): Promise<void> {
}
messageLoopRunning = true;
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
while (true) {
try {
@@ -422,10 +445,11 @@ async function startMessageLoop(): Promise<void> {
// Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives.
if (needsTrigger) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(m) =>
TRIGGER_PATTERN.test(m.content.trim()) &&
triggerPattern.test(m.content.trim()) &&
(m.is_from_me ||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);

View File

@@ -162,6 +162,7 @@ export async function processTaskIpc(
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
script?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
@@ -260,6 +261,7 @@ export async function processTaskIpc(
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
script: data.script || null,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
@@ -352,6 +354,7 @@ export async function processTaskIpc(
const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.script !== undefined) updates.script = data.script || null;
if (data.schedule_type !== undefined)
updates.schedule_type = data.schedule_type as
| 'cron'

View File

@@ -139,6 +139,7 @@ async function runTask(
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
@@ -179,6 +180,7 @@ async function runTask(
isMain,
isScheduledTask: true,
assistantName: ASSISTANT_NAME,
script: task.script || undefined,
},
(proc, containerName) =>
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),

View File

@@ -58,6 +58,7 @@ export interface ScheduledTask {
group_folder: string;
chat_jid: string;
prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;
context_mode: 'group' | 'isolated';