Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,29 @@ export const config = {
catch { return {}; }
})() as Record<string, unknown>,
},
worktreeSlugAI: {
/**
* Optional AI slugger for `/repo wt` auto branch/worktree naming. Disabled
* by default because it sends the topic title / first prompt to the
* configured OpenAI-compatible endpoint. When disabled or misconfigured,
* botmux falls back to deterministic local slugification.
*/
enabled: (process.env.BOTMUX_WORKTREE_SLUG_AI_ENABLED ?? '').toLowerCase() === 'true',
baseUrl: process.env.BOTMUX_WORKTREE_SLUG_AI_BASE_URL ?? '',
apiKey: process.env.BOTMUX_WORKTREE_SLUG_AI_API_KEY ?? '',
model: process.env.BOTMUX_WORKTREE_SLUG_AI_MODEL ?? '',
timeoutMs: Number(process.env.BOTMUX_WORKTREE_SLUG_AI_TIMEOUT_MS) || 5_000,
/** Extra headers for the API request (JSON string). */
extraHeaders: (() => {
try { return JSON.parse(process.env.BOTMUX_WORKTREE_SLUG_AI_EXTRA_HEADERS ?? '{}'); }
catch { return {}; }
})() as Record<string, string>,
/** Extra body params for the API request (JSON string). */
extraBody: (() => {
try { return JSON.parse(process.env.BOTMUX_WORKTREE_SLUG_AI_EXTRA_BODY ?? '{}'); }
catch { return {}; }
})() as Record<string, unknown>,
},
};

// allowedUsers is mutable — daemon resolves email prefixes to open_ids at startup
Expand Down
10 changes: 8 additions & 2 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as scheduleStore from '../services/schedule-store.js';
import * as scheduler from './scheduler.js';
import { scanProjects, scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js';
import { createRepoWorktree } from '../services/git-worktree.js';
import { worktreeSlugFromContextAI } from '../services/worktree-slug-ai.js';
import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard, buildLandCard } from '../im/lark/card-builder.js';
import { computeSandboxDiff } from '../services/sandbox-land.js';
import { createCliAdapterSync } from '../adapters/cli/registry.js';
Expand Down Expand Up @@ -1185,7 +1186,8 @@ export async function handleCommand(

// `/repo wt <N|name|path> [branch]` → create a worktree off the repo's
// remote default branch and open THAT as the session repo. Without a
// branch arg the branch/dir are auto-named (wt/N, <repo>-wt-N).
// branch arg the branch/dir are auto-named from the topic title / first
// pending prompt when possible (fallback: wt/N, <repo>-wt-N).
if (ds && /^wt(\s|$)/i.test(repoArg)) {
const rest = repoArg.replace(/^wt\s*/i, '').trim().split(/\s+/).filter(Boolean);
if (rest.length < 1 || rest.length > 2) {
Expand Down Expand Up @@ -1237,7 +1239,11 @@ export async function handleCommand(
await sessionReply(rootId, t('cmd.repo.worktree_creating', { repo: repoPath }, loc));
let creation;
try {
creation = await createRepoWorktree(repoPath, { branch: branchArg });
const slug = branchArg ? undefined : await worktreeSlugFromContextAI(ds!.session.title, ds!.pendingPrompt);
creation = await createRepoWorktree(repoPath, {
branch: branchArg,
slug,
});
} catch (e) {
await sessionReply(rootId, t('cmd.repo.worktree_failed', { error: e instanceof Error ? e.message : String(e) }, loc));
break;
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export const messages: Record<string, string> = {
'cmd.repo.scan_dir_not_exist': 'Scan dirs do not exist: {dirs}\nCheck that workingDir in bots.json points to a valid directory.',
'cmd.repo.working_dir_not_exist': '❌ Configured working directory does not exist or is not a directory: {dirs}\nCheck workingDir / workingDirs in ~/.botmux/bots.json, or run `botmux setup` and choose an existing directory.',
'cmd.repo.no_git_repos': 'No git repositories found under {dirs}.',
'cmd.repo.worktree_usage': 'Usage: `/repo wt <N|name|path> [new-branch]` — create a worktree off the repo\'s remote default branch and open it.',
'cmd.repo.worktree_usage': 'Usage: `/repo wt <N|name|path> [new-branch]` — create a worktree off the repo\'s remote default branch and open it; without a branch, Botmux auto-names it from the topic title / first prompt when possible.',
'cmd.repo.worktree_creating': '🌿 Creating a worktree for `{repo}` (includes a git fetch, may take a few seconds)…',
'cmd.repo.worktree_created': '🌿 Worktree created: `{path}`\nBranch `{branch}`, based on `{base}`',
'cmd.repo.worktree_failed': '❌ Worktree creation failed: {error}',
Expand Down Expand Up @@ -407,7 +407,7 @@ export const messages: Record<string, string> = {
'help.repo_list': '/repo - Pending selection: start in default dir; mid-session: show project picker',
'help.repo_n': '/repo <N> - Switch to project #N',
'help.repo_path': '/repo <path|name> - Use a path or a project name under workingDir, skipping the card',
'help.repo_wt': '/repo wt <N|name> [branch] - Create a worktree off the remote default branch and open it',
'help.repo_wt': '/repo wt <N|name> [branch] - Create a worktree off the remote default branch and open it (auto semantic name when branch is omitted)',
'help.status': '/status - Show current session status (incl. terminal URL)',
'help.card': '/card - Manually post the streaming card for this session (summons it even when streaming is off, and resumes live updates; with private-card mode on, sends a static snapshot visible only to authorized users instead)',
'help.term': '/term - Get the operable (write-enabled) terminal link for this session, delivered privately to the owner (visible-to-you in-chat, falling back to DM in topic/p2p — never exposed in the group)',
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export const messages: Record<string, string> = {
'cmd.repo.scan_dir_not_exist': '扫描目录不存在:{dirs}\n请检查 bots.json 中的 workingDir 是否指向有效目录。',
'cmd.repo.working_dir_not_exist': '❌ 配置的工作目录不存在或不是目录:{dirs}\n请检查 ~/.botmux/bots.json 中的 workingDir / workingDirs,或重新运行 `botmux setup` 修改为已存在的目录。',
'cmd.repo.no_git_repos': '在 {dirs} 下未找到 git 仓库。',
'cmd.repo.worktree_usage': '用法:`/repo wt <编号|项目名|路径> [新分支名]` — 基于该仓库的远端默认分支新建 worktree 并打开。',
'cmd.repo.worktree_usage': '用法:`/repo wt <编号|项目名|路径> [新分支名]` — 基于该仓库的远端默认分支新建 worktree 并打开;未指定分支时会优先根据话题标题/首条需求自动命名。',
'cmd.repo.worktree_creating': '🌿 正在为 `{repo}` 创建 worktree(含 git fetch,可能需要几秒)…',
'cmd.repo.worktree_created': '🌿 worktree 已创建:`{path}`\n分支 `{branch}`,基于 `{base}`',
'cmd.repo.worktree_failed': '❌ 创建 worktree 失败:{error}',
Expand Down Expand Up @@ -410,7 +410,7 @@ export const messages: Record<string, string> = {
'help.repo_list': '/repo - 仓库待选时直接在默认目录开会话;会话中则弹项目选择卡片',
'help.repo_n': '/repo <N> - 切换到第 N 个项目',
'help.repo_path': '/repo <路径|项目名> - 直接指定路径或 workingDir 下的项目名,跳过选择卡片',
'help.repo_wt': '/repo wt <编号|项目名> [分支] - 基于远端默认分支新建 worktree 并打开',
'help.repo_wt': '/repo wt <编号|项目名> [分支] - 基于远端默认分支新建 worktree 并打开(未指定分支时自动语义命名)',
'help.status': '/status - 查看当前会话状态(含终端链接)',
'help.card': '/card - 手动弹出当前会话的流式卡片(关流式时也能临时召唤,并恢复实时刷新;开了私密卡片则改发仅授权人可见的静态快照)',
'help.term': '/term - 获取当前会话的「可操作终端」(带写权限)链接,私密发给 owner(群内仅你可见,话题/单聊回退私信,不在群里暴露)',
Expand Down
4 changes: 3 additions & 1 deletion src/im/lark/card-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type { DaemonSession } from '../../core/types.js';
import { buildTerminalUrl } from '../../core/terminal-url.js';
import type { ProjectInfo } from '../../services/project-scanner.js';
import { createRepoWorktree } from '../../services/git-worktree.js';
import { worktreeSlugFromContextAI } from '../../services/worktree-slug-ai.js';
import { t, localeForBot, isLocale, type Locale } from '../../i18n/index.js';

// ─── Types ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1766,7 +1767,8 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
try {
let creation;
try {
creation = await createRepoWorktree(selectedPath);
const slug = await worktreeSlugFromContextAI(targetDs.session.title, targetDs.pendingPrompt);
creation = await createRepoWorktree(selectedPath, { slug });
} catch (e) {
logger.warn(`[${tag(targetDs)}] Worktree creation failed for ${selectedPath}: ${e instanceof Error ? e.message : e}`);
await sessionReply(rootId, t('cmd.repo.worktree_failed', { error: e instanceof Error ? e.message : String(e) }, locTarget));
Expand Down
57 changes: 54 additions & 3 deletions src/services/git-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* runs inside the daemon's event loop.
*/
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
Expand All @@ -25,6 +26,13 @@ export interface WorktreeCreation {
baseRef: string;
}

export interface CreateRepoWorktreeOptions {
/** Explicit branch to check out/create. Takes precedence over `slug`. */
branch?: string;
/** Semantic auto-name seed; creates `wt/<slug>` and dir `<repo>-wt-<slug>`. */
slug?: string;
}

async function git(args: string[], cwd: string, timeoutMs = 10_000): Promise<string> {
try {
const { stdout } = await execFileP('git', args, { cwd, timeout: timeoutMs, encoding: 'utf-8' });
Expand Down Expand Up @@ -65,7 +73,33 @@ async function resolveBaseRef(repo: string): Promise<string> {

/** Branch names may contain `/` etc. — flatten to a filesystem-safe suffix. */
function dirSuffixForBranch(branch: string): string {
return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'branch';
}

function shortHash(text: string): string {
return createHash('sha1').update(text).digest('hex').slice(0, 8);
}

/**
* Build a git/filesystem-safe semantic slug from a session title or the first
* prompt. Keep it ASCII so branch and directory names are portable; when the
* source text has no latin/digit tokens (for example, all-CJK text), fall back
* to a stable hash instead of returning an empty name.
*/
export function slugFromWorktreeText(text: string | undefined | null, fallback = 'task'): string | undefined {
const raw = text?.trim();
if (!raw) return undefined;
const slug = raw
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
.replace(/-+$/g, '');
if (slug) return slug;
return `${fallback}-${shortHash(raw)}`;
}

/** A linked worktree resolves to its repo's MAIN checkout (entry 0 of
Expand All @@ -81,7 +115,9 @@ async function resolveMainWorktree(dir: string): Promise<string> {
* Create a linked worktree for `repoPath`, as a sibling of the repo's MAIN
* checkout (a linked-worktree input is resolved back to the main one first).
*
* - No `branch` given → auto-pick `wt/N` (first free N), dir `<repo>-wt-N`.
* - No `branch` given, `slug` given → auto-pick `wt/<slug>` (or `-2` etc.),
* dir `<repo>-wt-<slug>`.
* - No `branch`/`slug` given → auto-pick `wt/N` (first free N), dir `<repo>-wt-N`.
* - `branch` given and exists locally → check it out into the worktree.
* - `branch` given and exists remotely → create a local tracking branch from it.
* - `branch` given and new → create it from the remote default branch.
Expand All @@ -91,7 +127,7 @@ async function resolveMainWorktree(dir: string): Promise<string> {
*/
export async function createRepoWorktree(
repoPath: string,
opts: { branch?: string } = {},
opts: CreateRepoWorktreeOptions = {},
): Promise<WorktreeCreation> {
const startDir = resolve(repoPath);
await git(['rev-parse', '--git-dir'], startDir); // not a repo → throw early
Expand All @@ -115,6 +151,21 @@ export async function createRepoWorktree(
if (branch) {
wtPath = join(parent, `${repoBase}-${dirSuffixForBranch(branch)}`);
if (existsSync(wtPath)) throw new Error(`worktree target already exists: ${wtPath}`);
} else if (opts.slug) {
const slug = slugFromWorktreeText(opts.slug, 'task');
if (!slug) throw new Error('invalid worktree slug');
for (let n = 1;; n++) {
if (n > 1000) throw new Error(`no free wt/${slug} slot under 1000`);
const candidateSlug = n === 1 ? slug : `${slug}-${n}`;
const candidateBranch = `wt/${candidateSlug}`;
const candPath = join(parent, `${repoBase}-${dirSuffixForBranch(candidateBranch)}`);
if (existsSync(candPath) ||
(await localBranchExists(repo, candidateBranch)) ||
(await remoteBranchExists(repo, candidateBranch))) continue;
branch = candidateBranch;
wtPath = candPath;
break;
}
} else {
let n = 1;
for (;; n++) {
Expand Down
82 changes: 82 additions & 0 deletions src/services/worktree-slug-ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { config } from '../config.js';
import { logger } from '../utils/logger.js';
import { slugFromWorktreeText } from './git-worktree.js';

const SYSTEM_PROMPT = `You generate short, stable git branch slugs for coding tasks.
Return ONLY one lowercase ASCII slug, no markdown, no quotes.
Rules:
- Translate Chinese or any non-English task into concise English keywords.
- Use 2 to 5 words when possible.
- Use only a-z, 0-9, and hyphen.
- Start and end with a letter or digit.
- Max 48 characters.
- Prefer concrete engineering terms over generic words.
Examples:
中文 worktree 命名逻辑 -> worktree-naming-logic
远端同名分支已存在时 checkout 逻辑 -> remote-branch-checkout
创建 PR 前自动跑测试 -> pre-pr-test-run
修复飞书卡片重复点击 -> lark-card-double-click`;

function firstText(title?: string, firstPrompt?: string): string | undefined {
const t = title?.trim();
if (t) return t;
const p = firstPrompt?.trim();
return p || undefined;
}

function sanitizeModelSlug(raw: string | undefined): string | undefined {
return slugFromWorktreeText(raw)?.slice(0, 48).replace(/-+$/g, '') || undefined;
}

function aiSlugConfigured(): boolean {
const c = config.worktreeSlugAI;
return !!(c.enabled && c.baseUrl && c.apiKey && c.model);
}

export function localWorktreeSlugFromContext(title?: string, firstPrompt?: string): string | undefined {
return slugFromWorktreeText(title) ?? slugFromWorktreeText(firstPrompt);
}

export async function worktreeSlugFromContextAI(title?: string, firstPrompt?: string): Promise<string | undefined> {
const fallback = localWorktreeSlugFromContext(title, firstPrompt);
if (!aiSlugConfigured()) return fallback;

const text = firstText(title, firstPrompt);
if (!text) return fallback;

const c = config.worktreeSlugAI;
try {
const url = `${c.baseUrl.replace(/\/+$/, '')}/chat/completions`;
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${c.apiKey}`,
...c.extraHeaders,
},
body: JSON.stringify({
model: c.model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: text.slice(0, 1_000) },
],
temperature: 0,
max_tokens: 32,
...c.extraBody,
}),
signal: AbortSignal.timeout(Math.max(500, c.timeoutMs)),
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(`AI slug API ${resp.status}: ${body.slice(0, 200)}`);
}
const json = await resp.json() as any;
const content = json?.choices?.[0]?.message?.content;
const slug = sanitizeModelSlug(typeof content === 'string' ? content : undefined);
if (slug) return slug;
logger.warn('[worktree-slug-ai] empty or invalid AI slug, using local fallback');
} catch (e) {
logger.warn(`[worktree-slug-ai] failed, using local fallback: ${e instanceof Error ? e.message : e}`);
}
return fallback;
}
8 changes: 8 additions & 0 deletions test/card-handler-repo-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ vi.mock('../src/services/git-worktree.js', () => ({
createRepoWorktree: vi.fn(),
}));

vi.mock('../src/services/worktree-slug-ai.js', () => ({
worktreeSlugFromContextAI: vi.fn(async (title?: string, firstPrompt?: string) => {
const text = title?.trim() || firstPrompt?.trim();
return text?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}),
}));

vi.mock('@larksuiteoapi/node-sdk', () => ({
Client: class { constructor() {} },
WSClient: class { start() {} },
Expand Down Expand Up @@ -256,6 +263,7 @@ describe('repo select card — worktree open', () => {
const second = await handleCardAction(makeSelectEvent('repo_worktree', '/repos/alpha'), deps, APP_ID);

expect(createRepoWorktree).toHaveBeenCalledTimes(1);
expect(createRepoWorktree).toHaveBeenCalledWith('/repos/alpha', { slug: 'repo-test' });
expect(first?.toast?.content).toContain('正在创建');
expect(second?.toast?.content).toContain('已有一个 worktree 正在创建');
expect(ds.worktreeCreating).toBe(true);
Expand Down
26 changes: 25 additions & 1 deletion test/command-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ vi.mock('../src/services/git-worktree.js', () => ({
createRepoWorktree: vi.fn(),
}));

vi.mock('../src/services/worktree-slug-ai.js', () => ({
worktreeSlugFromContextAI: vi.fn(async (title?: string, firstPrompt?: string) => {
const text = title?.trim() || firstPrompt?.trim();
return text?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}),
}));

vi.mock('../src/im/lark/card-builder.js', () => ({
buildRepoSelectCard: vi.fn(() => '{"card":"json"}'),
buildAdoptSelectCard: vi.fn(() => '{"card":"adopt-select"}'),
Expand Down Expand Up @@ -1234,14 +1241,31 @@ describe('handleCommand', () => {

await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo wt 1'), deps, LARK_APP_ID);

expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', { branch: undefined });
expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', {
branch: undefined,
slug: 'test-session',
});
expect(ds.workingDir).toBe('/home/testuser/project-a-wt-1');
expect(forkWorker).toHaveBeenCalledWith(ds, '', false);
expect(ds.worktreeCreating).toBe(false);
const replies = vi.mocked(deps.sessionReply).mock.calls.map(c => c[1]).join();
expect(replies).toContain('worktree 已创建');
});

it('keeps an explicit branch instead of auto semantic naming', async () => {
const ds = makeDaemonSession({ pendingRepo: false });
const deps = makeDeps(ds);
deps.lastRepoScan.set(CHAT_ID, SCAN as any);
vi.mocked(createRepoWorktree).mockResolvedValue({ ...CREATION, branch: 'feat/manual' });

await handleCommand('/repo', ROOT_ID, makeLarkMessage('/repo wt 1 feat/manual'), deps, LARK_APP_ID);

expect(createRepoWorktree).toHaveBeenCalledWith('/home/testuser/project-a', {
branch: 'feat/manual',
slug: undefined,
});
});

it('holds the in-flight lock through the created-notice reply (post-git window)', async () => {
const ds = makeDaemonSession({ pendingRepo: false });
const deps = makeDeps(ds);
Expand Down
Loading