Merge remote-tracking branch 'origin/main' into feature/pokemon/battle

This commit is contained in:
claude-code-best
2026-04-22 22:59:13 +08:00
271 changed files with 22537 additions and 6082 deletions

View File

@@ -1,18 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import type React from 'react'
import autonomyCommand from '../autonomy'
import type { LocalCommandResult } from '../../types/command'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
function expectTextResult(
result: LocalCommandResult,
): asserts result is Extract<LocalCommandResult, { type: 'text' }> {
if (result.type !== 'text')
throw new Error(`Expected text result, got ${result.type}`)
}
import { listAutonomyFlows } from '../../utils/autonomyFlows'
import {
createAutonomyQueuedPrompt,
@@ -25,11 +19,30 @@ import {
resetCommandQueue,
} from '../../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { writeRegistry } from '../../utils/pipeRegistry'
import { getAutonomyPanelBaseActionCountForTests } from '../autonomyPanel'
let tempDir = ''
let previousConfigDir: string | undefined
async function callAutonomy(args = ''): Promise<{
result?: string
}> {
const mod = await autonomyCommand.load()
let result: string | undefined
const onDone = (text: string) => {
result = text
}
await mod.call(onDone as any, {} as any, args)
return { result }
}
beforeEach(async () => {
tempDir = await createTempDir('autonomy-command-')
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
@@ -39,12 +52,30 @@ beforeEach(async () => {
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('/autonomy', () => {
test('without args renders the autonomy panel', async () => {
const mod = await autonomyCommand.load()
let onDoneCalled = false
const onDone = () => {
onDoneCalled = true
}
const jsx = await mod.call(onDone as any, {} as any, '')
// Without args, the panel JSX is returned (onDone is NOT called)
expect(jsx).not.toBeNull()
expect(onDoneCalled).toBe(false)
expect(getAutonomyPanelBaseActionCountForTests()).toBeGreaterThan(10)
})
test('status reports autonomy runs and managed flows separately', async () => {
const plainRun = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
@@ -76,14 +107,12 @@ describe('/autonomy', () => {
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('', {} as any)
const { result } = await callAutonomy('status')
expectTextResult(result)
expect(result.value).toContain('Autonomy runs: 2')
expect(result.value).toContain('Autonomy flows: 1')
expect(result.value).toContain('Completed: 1')
expect(result.value).toContain('Queued: 1')
expect(result).toContain('Autonomy runs: 2')
expect(result).toContain('Autonomy flows: 1')
expect(result).toContain('Completed: 1')
expect(result).toContain('Queued: 1')
})
test('runs subcommand lists recent autonomy runs', async () => {
@@ -94,12 +123,10 @@ describe('/autonomy', () => {
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('runs 5', {} as any)
const { result } = await callAutonomy('runs 5')
expectTextResult(result)
expect(result.value).toContain(queued!.autonomy!.runId)
expect(result.value).toContain('proactive-tick')
expect(result).toContain(queued!.autonomy!.runId)
expect(result).toContain('proactive-tick')
})
test('flows subcommand lists managed flows and flow subcommand shows detail', async () => {
@@ -124,18 +151,14 @@ describe('/autonomy', () => {
})
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const flowsResult = await callAutonomy('flows 5')
expect(flowsResult.result).toContain(flow!.flowId)
expect(flowsResult.result).toContain('managed')
const flowsResult = await mod.call('flows 5', {} as any)
expectTextResult(flowsResult)
expect(flowsResult.value).toContain(flow!.flowId)
expect(flowsResult.value).toContain('managed')
const flowResult = await mod.call(`flow ${flow!.flowId}`, {} as any)
expectTextResult(flowResult)
expect(flowResult.value).toContain(`Flow: ${flow!.flowId}`)
expect(flowResult.value).toContain('Mode: managed')
expect(flowResult.value).toContain('Current step: gather')
const flowResult = await callAutonomy(`flow ${flow!.flowId}`)
expect(flowResult.result).toContain(`Flow: ${flow!.flowId}`)
expect(flowResult.result).toContain('Mode: managed')
expect(flowResult.result).toContain('Current step: gather')
})
test('flow resume queues the next waiting step', async () => {
@@ -163,11 +186,9 @@ describe('/autonomy', () => {
expect(waitingStart).toBeNull()
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow resume ${flow!.flowId}`, {} as any)
const { result } = await callAutonomy(`flow resume ${flow!.flowId}`)
expectTextResult(result)
expect(result.value).toContain('Queued the next managed step')
expect(result).toContain('Queued the next managed step')
expect(getCommandQueueSnapshot()).toHaveLength(1)
expect(getCommandQueueSnapshot()[0]!.autonomy?.flowId).toBe(flow!.flowId)
})
@@ -197,12 +218,10 @@ describe('/autonomy', () => {
enqueuePendingNotification(queued!)
expect(getCommandQueueSnapshot()).toHaveLength(1)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const { result } = await callAutonomy(`flow cancel ${flow!.flowId}`)
const [cancelledFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('Cancelled flow')
expect(result).toContain('Cancelled flow')
expect(cancelledFlow!.status).toBe('cancelled')
expect(getCommandQueueSnapshot()).toHaveLength(0)
})
@@ -227,20 +246,132 @@ describe('/autonomy', () => {
await markAutonomyRunCompleted(queued!.autonomy!.runId, tempDir)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const { result } = await callAutonomy(`flow cancel ${flow!.flowId}`)
const [terminalFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('already terminal')
expect(result).toContain('already terminal')
expect(terminalFlow!.status).toBe('succeeded')
})
test('invalid subcommands return usage text', async () => {
const mod = await autonomyCommand.load()
const result = await mod.call('unknown', {} as any)
const { result } = await callAutonomy('unknown')
expectTextResult(result)
expect(result.value).toContain('Usage: /autonomy')
expect(result).toContain('Usage: /autonomy')
})
test('status --deep reports local autonomy health surfaces', async () => {
const run = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceLabel: 'nightly',
})
expect(run).not.toBeNull()
await mkdir(join(tempDir, '.claude'), { recursive: true })
await writeFile(
join(tempDir, '.claude', 'scheduled_tasks.json'),
JSON.stringify({
tasks: [
{
id: 'cron1',
cron: '0 9 * * *',
prompt: 'Daily check',
createdAt: Date.now(),
recurring: true,
},
],
}),
)
await mkdir(join(tempDir, '.claude', 'workflow-runs'), {
recursive: true,
})
await writeFile(
join(tempDir, '.claude', 'workflow-runs', 'workflow-1.json'),
JSON.stringify({
runId: 'workflow-1',
workflow: 'release',
status: 'running',
createdAt: 1,
updatedAt: 2,
currentStepIndex: 0,
steps: [
{
name: 'Run tests',
prompt: 'Run focused tests',
status: 'running',
startedAt: 2,
},
],
}),
)
const teamDir = join(process.env.CLAUDE_CONFIG_DIR ?? '', 'teams', 'alpha')
await mkdir(teamDir, { recursive: true })
await writeFile(
join(teamDir, 'config.json'),
JSON.stringify({
name: 'alpha',
createdAt: Date.now(),
leadAgentId: 'team-lead@alpha',
members: [
{
agentId: 'team-lead@alpha',
name: 'team-lead',
joinedAt: Date.now(),
tmuxPaneId: '',
cwd: tempDir,
subscriptions: [],
},
{
agentId: 'worker@alpha',
name: 'worker',
joinedAt: Date.now(),
tmuxPaneId: 'in-process',
cwd: tempDir,
subscriptions: [],
backendType: 'in-process',
isActive: false,
},
],
}),
)
await writeRegistry({
version: 1,
mainMachineId: 'machine-main-123456',
main: {
id: 'main-id',
pid: 123,
machineId: 'machine-main-123456',
startedAt: 1,
ip: '127.0.0.1',
mac: '00:11:22:33:44:55',
hostname: 'main-host',
pipeName: 'main-pipe',
},
subs: [],
})
const { result } = await callAutonomy('status --deep')
expect(result).toContain('# Autonomy Deep Status')
expect(result).toContain('Auto mode:')
expect(result).toContain('## Runs')
expect(result).toContain('Autonomy runs: 1')
expect(result).toContain('## Cron')
expect(result).toContain('Cron jobs: 1')
expect(result).toContain('## Workflow Runs')
expect(result).toContain('Workflow runs: 1')
expect(result).toContain('workflow-1: release: running')
expect(result).toContain('## Teams')
expect(result).toContain('alpha: teammates=1')
expect(result).toContain('@worker: idle backend=in-process')
expect(result).toContain('## Pipes')
expect(result).toContain('Pipe registry: 1 main, 0 sub(s)')
expect(result).toContain('## Runtime')
expect(result).toContain('Daemon:')
expect(result).toContain('## Remote Control')
expect(result).toContain('Remote Control:')
})
})

View File

@@ -1,125 +1,13 @@
import type { Command, LocalCommandCall } from '../types/command.js'
import {
formatAutonomyFlowDetail,
formatAutonomyFlowsList,
formatAutonomyFlowsStatus,
getAutonomyFlowById,
listAutonomyFlows,
requestManagedAutonomyFlowCancel,
} from '../utils/autonomyFlows.js'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
markAutonomyRunCancelled,
resumeManagedAutonomyFlowPrompt,
} from '../utils/autonomyRuns.js'
import {
enqueuePendingNotification,
removeByFilter,
} from '../utils/messageQueueManager.js'
function parseRunsLimit(raw?: string): number {
const parsed = Number.parseInt(raw ?? '', 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 10
}
return Math.min(parsed, 50)
}
const call: LocalCommandCall = async (args: string) => {
const [subcommand = 'status', arg1, arg2] = args.trim().split(/\s+/, 3)
const runs = await listAutonomyRuns()
const flows = await listAutonomyFlows()
if (subcommand === 'runs') {
return {
type: 'text',
value: formatAutonomyRunsList(runs, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flows') {
return {
type: 'text',
value: formatAutonomyFlowsList(flows, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flow') {
if (arg1 === 'cancel') {
const flowId = arg2 ?? ''
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
if (!cancelled) {
return {
type: 'text',
value: 'Autonomy flow not found.',
}
}
if (!cancelled.accepted) {
return {
type: 'text',
value: `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`,
}
}
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
for (const command of removed) {
if (command.autonomy?.runId) {
await markAutonomyRunCancelled(command.autonomy.runId)
}
}
return {
type: 'text',
value:
cancelled.flow.status === 'running'
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
: `Cancelled flow ${flowId}. Removed ${removed.length} queued step(s).`,
}
}
if (arg1 === 'resume') {
const flowId = arg2 ?? ''
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
if (!command) {
return {
type: 'text',
value: 'Autonomy flow is not waiting or was not found.',
}
}
enqueuePendingNotification(command)
return {
type: 'text',
value: `Queued the next managed step for flow ${flowId}.`,
}
}
return {
type: 'text',
value: formatAutonomyFlowDetail(await getAutonomyFlowById(arg1 ?? '')),
}
}
if (subcommand !== 'status' && subcommand !== '') {
return {
type: 'text',
value:
'Usage: /autonomy [status|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
}
}
return {
type: 'text',
value: [formatAutonomyRunsStatus(runs), formatAutonomyFlowsStatus(flows)].join('\n'),
}
}
import type { Command } from '../types/command.js'
const autonomy = {
type: 'local',
type: 'local-jsx',
name: 'autonomy',
description:
'Inspect automatic autonomy runs recorded for proactive ticks and scheduled tasks',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
argumentHint:
'[status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
load: () => import('./autonomyPanel.js'),
} satisfies Command
export default autonomy

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Text, useInput } from '@anthropic/ink';
import { Dialog } from '@anthropic/ink';
import { useRegisterOverlay } from '../context/overlayContext.js';
import type { LocalJSXCommandOnDone } from '../types/command.js';
import { getAutonomyCommandText, getAutonomyDeepSectionText, getAutonomyStatusText } from '../cli/handlers/autonomy.js';
import { listAutonomyFlows, type AutonomyFlowRecord } from '../utils/autonomyFlows.js';
type AutonomyAction = {
label: string;
description: string;
run: () => Promise<string>;
};
const BASE_AUTONOMY_PANEL_ACTION_COUNT = 14;
const ACTION_LABEL_COLUMN_WIDTH = 24;
export function getAutonomyPanelBaseActionCountForTests(): number {
return BASE_AUTONOMY_PANEL_ACTION_COUNT;
}
function AutonomyPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
useRegisterOverlay('autonomy-panel');
const [selectedIndex, setSelectedIndex] = useState(0);
const [flows, setFlows] = useState<AutonomyFlowRecord[]>([]);
useEffect(() => {
let cancelled = false;
void listAutonomyFlows().then(items => {
if (!cancelled) setFlows(items.slice(0, 5));
});
return () => {
cancelled = true;
};
}, []);
const actions = useMemo<AutonomyAction[]>(() => {
const base: AutonomyAction[] = [
{
label: 'Overview',
description: 'Show run and flow counts plus the latest automatic activity',
run: () => getAutonomyStatusText(),
},
{
label: 'Full deep status',
description: 'Print every local autonomy surface in one diagnostic report',
run: () => getAutonomyStatusText({ deep: true }),
},
{
label: 'Auto mode',
description: 'Check whether auto permission mode is available and why',
run: () => getAutonomyDeepSectionText('auto-mode'),
},
{
label: 'Runs summary',
description: 'Show queued/running/completed/failed run totals and latest run',
run: () => getAutonomyDeepSectionText('runs'),
},
{
label: 'Recent runs',
description: 'List recent autonomy run IDs, triggers, statuses, and prompts',
run: () => getAutonomyCommandText('runs 10'),
},
{
label: 'Flows summary',
description: 'Show managed flow totals across queued/running/waiting states',
run: () => getAutonomyDeepSectionText('flows'),
},
{
label: 'Recent flows',
description: 'List recent managed flow IDs, status, current step, and goal',
run: () => getAutonomyCommandText('flows 10'),
},
{
label: 'Cron',
description: 'Show scheduled autonomy jobs, durability, recurrence, and next run',
run: () => getAutonomyDeepSectionText('cron'),
},
{
label: 'Workflow runs',
description: 'Show persisted WorkflowTool runs and their current workflow step',
run: () => getAutonomyDeepSectionText('workflow-runs'),
},
{
label: 'Teams',
description: 'Show Agent Teams, teammate backends, activity, and open tasks',
run: () => getAutonomyDeepSectionText('teams'),
},
{
label: 'Pipes',
description: 'Show UDS/named-pipe and LAN registry for terminal messaging',
run: () => getAutonomyDeepSectionText('pipes'),
},
{
label: 'Runtime',
description: 'Show daemon state and live background or interactive sessions',
run: () => getAutonomyDeepSectionText('runtime'),
},
{
label: 'Remote Control',
description: 'Show bridge mode, base URL, token presence, and entitlement note',
run: () => getAutonomyDeepSectionText('remote-control'),
},
{
label: 'RemoteTrigger',
description: 'Show recent remote trigger audit records, failures, and latest call',
run: () => getAutonomyDeepSectionText('remote-trigger'),
},
];
const flowActions = flows.flatMap<AutonomyAction>(flow => {
const shortId = flow.flowId.slice(0, 8);
const items: AutonomyAction[] = [
{
label: `Flow ${shortId}`,
description: `${flow.status}: ${flow.goal}`,
run: () => getAutonomyCommandText(`flow ${flow.flowId}`),
},
];
if (flow.status === 'waiting') {
items.push({
label: `Resume ${shortId}`,
description: flow.currentStep ? `Resume waiting step: ${flow.currentStep}` : 'Resume waiting flow',
run: () =>
getAutonomyCommandText(`flow resume ${flow.flowId}`, {
enqueueInMemory: true,
}),
});
}
if (
flow.status === 'queued' ||
flow.status === 'running' ||
flow.status === 'waiting' ||
flow.status === 'blocked'
) {
items.push({
label: `Cancel ${shortId}`,
description: `Cancel ${flow.status} flow`,
run: () =>
getAutonomyCommandText(`flow cancel ${flow.flowId}`, {
removeQueuedInMemory: true,
}),
});
}
return items;
});
return [...base, ...flowActions];
}, [flows]);
const selectCurrent = () => {
const action = actions[selectedIndex];
if (!action) return;
void action.run().then(result => {
onDone(result, { display: 'system' });
});
};
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex(index => Math.max(0, index - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
return;
}
if (key.return) {
selectCurrent();
}
});
return (
<Dialog
title="Autonomy"
subtitle={`${actions.length} actions`}
onCancel={() => onDone('Autonomy panel dismissed', { display: 'system' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{actions.map((action, index) => (
<Box key={`${action.label}-${index}`} flexDirection="row">
<Text>{`${index === selectedIndex ? '' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{action.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
const trimmed = args?.trim() ?? '';
if (trimmed) {
const result = await getAutonomyCommandText(trimmed, {
enqueueInMemory: true,
removeQueuedInMemory: true,
});
onDone(result, { display: 'system' });
return null;
}
return <AutonomyPanel onDone={onDone} />;
}

View File

@@ -54,7 +54,6 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly)
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: bridge starts once, should not restart on state changes
useEffect(() => {
// If already connected or enabled in full bidirectional mode, show
// disconnect confirmation. Outbound-only (CCR mirror) doesn't count —

View File

@@ -5,7 +5,7 @@ export default {
type: 'local-jsx',
name: 'effort',
description: 'Set effort level for model usage',
argumentHint: '[low|medium|high|max|auto]',
argumentHint: '[low|medium|high|xhigh|max|auto]',
get immediate() {
return shouldInferenceConfigCommandBeImmediate()
},

View File

@@ -52,7 +52,7 @@ const forceSnip = {
name: 'force-snip',
description: 'Force snip conversation history at current point',
supportsNonInteractive: true,
isHidden: true,
isHidden: false,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@@ -3058,7 +3058,6 @@ const usageReport: Command = {
// Show collection message if collecting
if (collectRemote && hasRemoteHosts) {
// biome-ignore lint/suspicious/noConsole: intentional
console.error(
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
)

View File

@@ -160,7 +160,7 @@ function SetModelAndClose({
// @[MODEL LAUNCH]: Update check for 1M access.
if (model && isOpus1mUnavailable(model)) {
onDone(
`Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
`Opus 4.7 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
{ display: 'system' },
)
return

View File

@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { call } from '../skill-learning.js'
import {
recordSkillGap,
saveInstinct,
createInstinct,
resolveProjectContext,
} from '../../../services/skillLearning/index.js'
let root: string
const originalEnv = { ...process.env }
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'skill-learning-command-'))
process.env = { ...originalEnv }
process.env.CLAUDE_SKILL_LEARNING_HOME = root
process.env.CLAUDE_CONFIG_DIR = join(root, 'config')
process.env.SKILL_LEARNING_ENABLED = '1'
})
afterEach(() => {
process.env = { ...originalEnv }
rmSync(root, { recursive: true, force: true })
})
describe('skill-learning command', () => {
test('status reports observations and instincts', async () => {
const result = await call('status', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Skill Learning status')
expect(result.value).toContain('Observations: 0')
}
})
test('promote (no args) prints usage and candidate summary', async () => {
const result = await call('promote', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promotion candidates')
expect(result.value).toContain('promote gap')
expect(result.value).toContain('promote instinct')
}
})
test('promote gap <key> promotes a pending gap to draft', async () => {
const project = resolveProjectContext(process.cwd())
const gap = await recordSkillGap({
prompt: 'refactor the api gateway',
cwd: process.cwd(),
project,
rootDir: root,
})
expect(gap.status).toBe('pending')
const result = await call(`promote gap ${gap.key}`, {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promoted gap')
expect(result.value).toContain('status=draft')
}
})
test('promote gap <unknown-key> reports not found', async () => {
const result = await call('promote gap does-not-exist', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('No gap found')
}
})
test('promote instinct <id> copies a project instinct to global scope', async () => {
const project = resolveProjectContext(process.cwd())
const instinct = createInstinct({
trigger: 'when committing',
action: 'run tests first',
confidence: 0.85,
domain: 'testing',
source: 'session-observation',
scope: 'project',
projectId: project.projectId,
projectName: project.projectName,
evidence: ['observed twice'],
})
await saveInstinct(instinct, { project, rootDir: root })
const result = await call(`promote instinct ${instinct.id}`, {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promoted instinct')
expect(result.value).toContain('global scope')
}
})
test('projects lists known project scopes', async () => {
// Resolving once registers the current project in the registry.
resolveProjectContext(root)
const result = await call('projects', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(
result.value.includes('Known project scopes') ||
result.value.includes('No known project scopes'),
).toBe(true)
}
})
test('default help mentions promote and projects, no write-fixture', async () => {
const result = await call('unknown-sub', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('promote')
expect(result.value).toContain('projects')
expect(result.value).not.toContain('write-fixture')
}
})
test('ingest imports transcript observations and instincts', async () => {
const transcript = join(root, 'session.jsonl')
writeFileSync(
transcript,
JSON.stringify({
type: 'user',
sessionId: 's1',
cwd: root,
message: { role: 'user', content: '不要 mock用 testing-library' },
}) + '\n',
)
// Pass --min-session-length=0 so the 1-line test transcript is not skipped
// by the ECC-parity gate (default threshold: 10 observations).
const result = await call(
`ingest ${transcript} --min-session-length=0`,
{} as any,
)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Ingested')
expect(result.value).toContain('saved 1 instincts')
}
})
})

View File

@@ -0,0 +1,15 @@
import type { Command } from '../../commands.js'
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js'
const skillLearning = {
type: 'local-jsx',
name: 'skill-learning',
description: 'Manage skill learning (observe, analyze, evolve)',
argumentHint:
'[start|stop|about|status|ingest|evolve|export|import|prune|promote|projects]',
isEnabled: () => isSkillLearningEnabled(),
isHidden: false,
load: () => import('./skillPanel.js'),
} satisfies Command
export default skillLearning

View File

@@ -0,0 +1,310 @@
import { join } from 'node:path'
import type { LocalCommandCall } from '../../types/command.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import {
analyzeObservations,
applySkillLifecycleDecision,
compareExistingSkills,
decideSkillLifecycle,
exportInstincts,
findPromotionCandidates,
generateSkillCandidates,
importInstincts,
ingestTranscript,
listKnownProjects,
loadInstincts,
promoteGapToDraft,
prunePendingInstincts,
readObservations,
readSkillGaps,
resolveProjectContext,
saveInstinct,
upsertInstinct,
} from '../../services/skillLearning/index.js'
export const call: LocalCommandCall = async (
args,
): Promise<{ type: 'text'; value: string }> => {
const parts = args.trim().split(/\s+/).filter(Boolean)
const sub = parts[0] ?? 'status'
const project = resolveProjectContext(process.cwd())
const rootDir = process.env.CLAUDE_SKILL_LEARNING_HOME
const options = { project, rootDir }
switch (sub) {
case 'status': {
const [observations, instincts] = await Promise.all([
readObservations(options),
loadInstincts(options),
])
return {
type: 'text',
value: [
`Skill Learning status for ${project.projectName} (${project.projectId})`,
`Observations: ${observations.length}`,
`Instincts: ${instincts.length}`,
].join('\n'),
}
}
case 'ingest': {
const transcript = parts[1]
if (!transcript) {
return {
type: 'text',
value:
'Usage: /skill-learning ingest <transcript.jsonl> [--min-session-length=<n>]',
}
}
const minSessionLength = parseFlagNumber(
parts,
'--min-session-length',
10,
)
const observations = await ingestTranscript(transcript, options)
if (observations.length < minSessionLength) {
return {
type: 'text',
value: `Session too short for learning (${observations.length} < min=${minSessionLength}). Skipping instinct extraction.`,
}
}
const instincts = analyzeObservations(observations)
const saved = []
for (const instinct of instincts) {
saved.push(await upsertInstinct(instinct, options))
}
return {
type: 'text',
value: `Ingested ${observations.length} observations and saved ${saved.length} instincts.`,
}
}
case 'evolve': {
const generate = parts.includes('--generate')
const instincts = await loadInstincts(options)
const drafts = generateSkillCandidates(instincts, { cwd: process.cwd() })
const written = []
if (generate) {
for (const draft of drafts) {
const roots = [
join(process.cwd(), '.claude', 'skills'),
join(getClaudeConfigHomeDir(), 'skills'),
]
const existing = await compareExistingSkills(draft, roots)
const decision = decideSkillLifecycle(draft, existing)
const result = await applySkillLifecycleDecision(decision)
written.push(
`${decision.type}: ${result.activePath ?? result.archivedPath ?? result.deletedPath ?? 'no active write'}`,
)
}
}
return {
type: 'text',
value: generate
? `Generated ${written.length} learned skill(s):\n${written.join('\n')}`
: `Found ${drafts.length} skill candidate(s). Use --generate to write them.`,
}
}
case 'export': {
const output = parts[1] ?? 'skill-learning-instincts.json'
const scope = parseFlagString(parts, '--scope')
const minConf = parseFlagNumber(parts, '--min-conf', undefined)
const domain = parseFlagString(parts, '--domain')
const filter = (instincts: Awaited<ReturnType<typeof loadInstincts>>) =>
instincts.filter(i => {
if (scope && i.scope !== scope) return false
if (minConf !== undefined && i.confidence < minConf) return false
if (domain && i.domain !== domain) return false
return true
})
const all = await loadInstincts(options)
const filtered = filter(all)
if (filtered.length !== all.length) {
await exportInstincts(output, options)
// Re-write with filtered payload to honor filter args.
const { writeFile } = await import('node:fs/promises')
await writeFile(output, `${JSON.stringify(filtered, null, 2)}\n`)
} else {
await exportInstincts(output, options)
}
const parts2: string[] = [
`Exported ${filtered.length} instincts to ${output}`,
]
if (scope || minConf !== undefined || domain) {
const filters: string[] = []
if (scope) filters.push(`scope=${scope}`)
if (minConf !== undefined) filters.push(`min-conf=${minConf}`)
if (domain) filters.push(`domain=${domain}`)
parts2.push(`(filters: ${filters.join(', ')})`)
}
return { type: 'text', value: parts2.join(' ') }
}
case 'import': {
const input = parts[1]
if (!input) {
return {
type: 'text',
value:
'Usage: /skill-learning import <instincts.json> [--scope=<scope>] [--min-conf=<n>] [--domain=<d>] [--dry-run]',
}
}
const scope = parseFlagString(parts, '--scope')
const minConf = parseFlagNumber(parts, '--min-conf', undefined)
const domain = parseFlagString(parts, '--domain')
const dryRun = parts.includes('--dry-run')
// Read + filter first so --dry-run can truly skip persistence. The
// previous `importInstincts(...)` call wrote to disk before branching
// on --dry-run, which defeated the purpose of the flag.
const { readFile: readFileFs } = await import('node:fs/promises')
const parsed = JSON.parse(await readFileFs(input, 'utf8')) as Awaited<
ReturnType<typeof loadInstincts>
>
const filtered = parsed.filter(i => {
if (scope && i.scope !== scope) return false
if (minConf !== undefined && i.confidence < minConf) return false
if (domain && i.domain !== domain) return false
return true
})
if (dryRun) {
return {
type: 'text',
value: `Dry run: would import ${filtered.length}/${parsed.length} instincts.`,
}
}
for (const instinct of filtered) {
await upsertInstinct(instinct, options)
}
return {
type: 'text',
value: `Imported ${filtered.length}/${parsed.length} instincts.`,
}
}
case 'prune': {
const maxAgeIndex = parts.indexOf('--max-age')
const maxAge =
maxAgeIndex >= 0 && parts[maxAgeIndex + 1]
? Number(parts[maxAgeIndex + 1])
: 30
const pruned = await prunePendingInstincts(maxAge, options)
return {
type: 'text',
value: `Pruned ${pruned.length} pending instincts.`,
}
}
case 'promote': {
const target = parts[1]
if (!target) {
const gaps = await readSkillGaps(project, rootDir)
const instincts = await loadInstincts(options)
const candidates = findPromotionCandidates(instincts)
const lines = [
`Promotion candidates for ${project.projectName} (${project.projectId}):`,
`Pending gaps: ${gaps.filter(g => g.status === 'pending').length}`,
`Global-eligible instincts (>=2 projects, avg confidence >=0.8): ${candidates.length}`,
'',
'Usage:',
' /skill-learning promote gap <gap-key> # pending gap -> draft',
' /skill-learning promote instinct <instinct-id> # project instinct -> global',
]
return { type: 'text', value: lines.join('\n') }
}
if (target === 'gap') {
const gapKey = parts[2]
if (!gapKey) {
return {
type: 'text',
value: 'Usage: /skill-learning promote gap <gap-key>',
}
}
const updated = await promoteGapToDraft(gapKey, project, rootDir)
if (!updated) {
return { type: 'text', value: `No gap found for key "${gapKey}".` }
}
return {
type: 'text',
value: `Promoted gap ${gapKey} to status=${updated.status} (draft=${updated.draft?.skillPath ?? 'none'}).`,
}
}
if (target === 'instinct') {
const instinctId = parts[2]
if (!instinctId) {
return {
type: 'text',
value: 'Usage: /skill-learning promote instinct <instinct-id>',
}
}
const projectInstincts = await loadInstincts(options)
const match = projectInstincts.find(i => i.id === instinctId)
if (!match) {
return {
type: 'text',
value: `No project-scoped instinct found for id "${instinctId}".`,
}
}
if (match.scope === 'global') {
return {
type: 'text',
value: `Instinct ${instinctId} is already global.`,
}
}
const globalCopy = { ...match, scope: 'global' as const }
await saveInstinct(globalCopy, { scope: 'global', rootDir })
return {
type: 'text',
value: `Promoted instinct ${instinctId} to global scope.`,
}
}
return {
type: 'text',
value:
'Usage: /skill-learning promote [gap <gap-key>|instinct <instinct-id>]',
}
}
case 'projects': {
const projects = listKnownProjects()
if (projects.length === 0) {
return { type: 'text', value: 'No known project scopes yet.' }
}
const lines = ['Known project scopes:']
for (const record of projects) {
const projectOptions = { project: record, rootDir }
const [instincts, observations] = await Promise.all([
loadInstincts(projectOptions),
readObservations(projectOptions),
])
lines.push(
`- ${record.projectName} (${record.projectId}) — instincts: ${instincts.length}, observations: ${observations.length}, lastSeen: ${record.lastSeenAt}`,
)
}
return { type: 'text', value: lines.join('\n') }
}
default:
return {
type: 'text',
value:
'Usage: /skill-learning [status|ingest|evolve|export|import|prune|promote|projects]',
}
}
}
function parseFlagString(parts: string[], flag: string): string | undefined {
const eqForm = parts.find(p => p.startsWith(`${flag}=`))
if (eqForm) return eqForm.slice(flag.length + 1) || undefined
const idx = parts.indexOf(flag)
if (idx >= 0 && parts[idx + 1] && !parts[idx + 1].startsWith('--')) {
return parts[idx + 1]
}
return undefined
}
function parseFlagNumber<T extends number | undefined>(
parts: string[],
flag: string,
fallback: T,
): number | T {
const raw = parseFlagString(parts, flag)
if (raw === undefined) return fallback
const value = Number(raw)
return Number.isFinite(value) ? value : fallback
}

View File

@@ -0,0 +1,197 @@
import React, { useMemo, useState } from 'react';
import { Box, Text, useInput } from '@anthropic/ink';
import { Dialog } from '@anthropic/ink';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js';
type SkillAction = {
label: string;
description: string;
run: () => Promise<string>;
};
const ACTION_LABEL_COLUMN_WIDTH = 28;
const ABOUT_TEXT = `# Skill Learning (自动学习)
Skill Learning 是一个闭环学习系统,通过观察用户的操作模式自动提取直觉(instinct)
并在达到阈值后生成可复用的 skill 文件、agent 和 command。
## 工作流程
1. **Observe** — 记录每轮对话中的工具调用、用户纠正、错误解决模式
2. **Analyze** — 使用启发式或 LLM 后端分析观察数据,提取 instinct candidate
3. **Evolve** — 将高置信度 instinct 聚类,生成 skill/agent/command 候选
4. **Lifecycle** — 对生成的 skill 进行去重、版本比较、归档或替换
## 子命令
- /skill-learning status — 查看当前项目的观察和直觉数量
- /skill-learning ingest — 从 transcript 导入观察数据
- /skill-learning evolve — 生成 skill 候选 (--generate 写入磁盘)
- /skill-learning export — 导出 instinct 为 JSON
- /skill-learning import — 导入 instinct JSON
- /skill-learning prune — 清理过期的 pending instinct
- /skill-learning promote — 将 instinct/gap 提升为全局范围
- /skill-learning projects — 列出所有已知的项目范围
## 启用方式
- SKILL_LEARNING_ENABLED=1 或 FEATURE_SKILL_LEARNING=1
- 状态: ${isSkillLearningEnabled() ? '已启用' : '未启用'}
`;
async function getStatusText(): Promise<string> {
const { readObservations, loadInstincts, resolveProjectContext } = await import(
'../../services/skillLearning/index.js'
);
const project = resolveProjectContext(process.cwd());
const [observations, instincts] = await Promise.all([readObservations({ project }), loadInstincts({ project })]);
return [
`Skill Learning status for ${project.projectName} (${project.projectId})`,
`Observations: ${observations.length}`,
`Instincts: ${instincts.length}`,
'',
`Skill Learning: ${isSkillLearningEnabled() ? 'enabled' : 'disabled'}`,
].join('\n');
}
async function startSkillLearning(): Promise<string> {
const lines: string[] = [];
if (!isSkillLearningEnabled()) {
process.env.SKILL_LEARNING_ENABLED = '1';
lines.push('Skill Learning: enabled (SKILL_LEARNING_ENABLED=1)');
} else {
lines.push('Skill Learning: already enabled');
}
try {
const { initSkillLearning } = await import('../../services/skillLearning/runtimeObserver.js');
initSkillLearning();
lines.push('Runtime observer: initialized');
} catch {
lines.push('Runtime observer: init skipped (not available)');
}
return lines.join('\n');
}
async function stopSkillLearning(): Promise<string> {
const lines: string[] = [];
if (isSkillLearningEnabled()) {
process.env.SKILL_LEARNING_ENABLED = '0';
process.env.CLAUDE_SKILL_LEARNING_DISABLE = '1';
lines.push('Skill Learning: disabled (SKILL_LEARNING_ENABLED=0)');
} else {
lines.push('Skill Learning: already disabled');
}
return lines.join('\n');
}
function SkillPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
useRegisterOverlay('skill-panel');
const [selectedIndex, setSelectedIndex] = useState(0);
const actions = useMemo<SkillAction[]>(
() => [
{
label: 'Status',
description: 'Show skill learning status for current project',
run: getStatusText,
},
{
label: 'Start',
description: 'Enable skill learning for this session',
run: startSkillLearning,
},
{
label: 'Stop',
description: 'Disable skill learning for this session',
run: stopSkillLearning,
},
{
label: 'About',
description: 'Detailed description of skill learning features',
run: () => Promise.resolve(ABOUT_TEXT),
},
],
[],
);
const selectCurrent = () => {
const action = actions[selectedIndex];
if (!action) return;
void action.run().then(result => {
onDone(result, { display: 'system' });
});
};
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex(index => Math.max(0, index - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
return;
}
if (key.return) {
selectCurrent();
}
});
return (
<Dialog
title="Skill Learning"
subtitle={`${actions.length} actions`}
onCancel={() => onDone('Skill panel dismissed', { display: 'system' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{actions.map((action, index) => (
<Box key={action.label} flexDirection="row">
<Text>{`${index === selectedIndex ? '' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{action.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
const trimmed = args?.trim() ?? '';
if (trimmed === 'start') {
onDone(await startSkillLearning(), { display: 'system' });
return null;
}
if (trimmed === 'stop') {
onDone(await stopSkillLearning(), { display: 'system' });
return null;
}
if (trimmed === 'about') {
onDone(ABOUT_TEXT, { display: 'system' });
return null;
}
if (trimmed === 'status') {
onDone(await getStatusText(), { display: 'system' });
return null;
}
if (trimmed) {
const { call: textCall } = await import('./skill-learning.js');
const result = await textCall(trimmed, {} as any);
if (result && typeof result === 'object' && 'value' in result) {
onDone((result as { value: string }).value, { display: 'system' });
}
return null;
}
return <SkillPanel onDone={onDone} />;
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const skillSearch = {
type: 'local-jsx',
name: 'skill-search',
description: 'Control automatic skill matching during conversations',
argumentHint: '[start|stop|about|status]',
isHidden: false,
load: () => import('./skillSearchPanel.js'),
} satisfies Command
export default skillSearch

View File

@@ -0,0 +1,169 @@
import React, { useMemo, useState } from 'react';
import { Box, Text, useInput } from '@anthropic/ink';
import { Dialog } from '@anthropic/ink';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { isSkillSearchEnabled } from '../../services/skillSearch/featureCheck.js';
type SkillSearchAction = {
label: string;
description: string;
run: () => Promise<string>;
};
const ACTION_LABEL_COLUMN_WIDTH = 28;
const ABOUT_TEXT = `# Skill Search (自动技能匹配)
Skill Search 控制对话中的自动技能匹配功能。
启用后Claude Code 会在每轮对话中自动搜索并加载与当前任务最相关的 skill 文件,
无需手动指定。搜索基于 TF-IDF 向量余弦相似度,支持英文词干化和 CJK bi-gram 分词。
## 工作原理
1. 对话开始时,自动索引 .claude/skills/ 和 ~/.claude/skills/ 下的 Markdown 文件
2. 每轮对话根据上下文自动匹配最相关的 skill
3. 匹配到的 skill 内容会作为上下文注入,指导 Claude Code 的行为
## 控制方式
- /skill-search start — 启用自动匹配
- /skill-search stop — 禁用自动匹配
- /skill-search status — 查看当前状态
当前状态: ${isSkillSearchEnabled() ? '已启用' : '未启用'}
`;
function getStatusText(): string {
return [
'Skill Search (自动技能匹配)',
`Status: ${isSkillSearchEnabled() ? 'enabled' : 'disabled'}`,
'',
'When enabled, relevant skills are automatically matched and',
'injected into conversation context each turn.',
].join('\n');
}
async function startSkillSearch(): Promise<string> {
if (isSkillSearchEnabled() && process.env.SKILL_SEARCH_ENABLED !== '0') {
return 'Skill Search: already enabled';
}
process.env.SKILL_SEARCH_ENABLED = '1';
const lines = ['Skill Search: enabled (SKILL_SEARCH_ENABLED=1)'];
try {
const { clearSkillIndexCache } = await import('../../services/skillSearch/localSearch.js');
clearSkillIndexCache();
lines.push('Skill index cache: cleared (will rebuild on next search)');
} catch {
lines.push('Skill index cache: clear skipped');
}
return lines.join('\n');
}
async function stopSkillSearch(): Promise<string> {
if (!isSkillSearchEnabled()) {
return 'Skill Search: already disabled';
}
process.env.SKILL_SEARCH_ENABLED = '0';
return 'Skill Search: disabled (SKILL_SEARCH_ENABLED=0)';
}
function SkillSearchPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
useRegisterOverlay('skill-search-panel');
const [selectedIndex, setSelectedIndex] = useState(0);
const actions = useMemo<SkillSearchAction[]>(
() => [
{
label: 'Status',
description: 'Show whether automatic skill matching is active',
run: () => Promise.resolve(getStatusText()),
},
{
label: 'Start',
description: 'Enable automatic skill matching for this session',
run: startSkillSearch,
},
{
label: 'Stop',
description: 'Disable automatic skill matching for this session',
run: stopSkillSearch,
},
{
label: 'About',
description: 'How automatic skill matching works',
run: () => Promise.resolve(ABOUT_TEXT),
},
],
[],
);
const selectCurrent = () => {
const action = actions[selectedIndex];
if (!action) return;
void action.run().then(result => {
onDone(result, { display: 'system' });
});
};
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex(index => Math.max(0, index - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
return;
}
if (key.return) {
selectCurrent();
}
});
return (
<Dialog
title="Skill Search"
subtitle={`${actions.length} actions`}
onCancel={() => onDone('Skill search panel dismissed', { display: 'system' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{actions.map((action, index) => (
<Box key={action.label} flexDirection="row">
<Text>{`${index === selectedIndex ? '' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{action.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
const trimmed = args?.trim() ?? '';
if (trimmed === 'start') {
onDone(await startSkillSearch(), { display: 'system' });
return null;
}
if (trimmed === 'stop') {
onDone(await stopSkillSearch(), { display: 'system' });
return null;
}
if (trimmed === 'about') {
onDone(ABOUT_TEXT, { display: 'system' });
return null;
}
if (trimmed === 'status') {
onDone(getStatusText(), { display: 'system' });
return null;
}
return <SkillSearchPanel onDone={onDone} />;
}

View File

@@ -0,0 +1,91 @@
import { describe, test, expect, mock, beforeEach } from 'bun:test'
const mockManuallyExtract = mock(
(): Promise<any> => Promise.resolve({ success: true }),
)
const mockGetContent = mock(
(): Promise<any> => Promise.resolve('# Session Summary\n\nDid some work.'),
)
mock.module(
require.resolve('../../../services/SessionMemory/sessionMemory.js'),
() => ({
manuallyExtractSessionMemory: mockManuallyExtract,
}),
)
mock.module(
require.resolve('../../../services/SessionMemory/sessionMemoryUtils.js'),
() => ({
getSessionMemoryContent: mockGetContent,
}),
)
const { default: summaryCommand } = await import('../index.js')
const baseContext = {
messages: [{ type: 'user', role: 'user', content: 'hello' }],
options: { tools: [], mainLoopModel: 'test' },
setMessages: () => {},
onChangeAPIKey: () => {},
} as any
async function callSummary(ctx = baseContext) {
const mod = await summaryCommand.load()
return mod.call('', ctx)
}
beforeEach(() => {
mockManuallyExtract.mockReset()
mockGetContent.mockReset()
mockManuallyExtract.mockImplementation(() =>
Promise.resolve({ success: true }),
)
mockGetContent.mockImplementation(() =>
Promise.resolve('# Session Summary\n\nDid some work.'),
)
})
describe('summary command', () => {
test('command metadata', () => {
expect(summaryCommand.name).toBe('summary')
expect(summaryCommand.type).toBe('local')
expect(summaryCommand.isHidden).toBe(false)
expect(typeof summaryCommand.load).toBe('function')
})
test('refreshes and displays summary', async () => {
const result = await callSummary()
expect(result.type).toBe('text')
expect((result as any).value).toContain('Session summary updated.')
expect((result as any).value).toContain('Did some work.')
expect(mockManuallyExtract).toHaveBeenCalled()
})
test('handles extraction failure', async () => {
mockManuallyExtract.mockImplementation(() =>
Promise.resolve({ success: false, error: 'timeout' }),
)
const result = await callSummary()
expect((result as any).value).toContain(
'Failed to generate session summary',
)
expect((result as any).value).toContain('timeout')
})
test('handles empty content after extraction', async () => {
mockGetContent.mockImplementation(() => Promise.resolve(''))
const result = await callSummary()
expect((result as any).value).toContain('content is empty')
})
test('handles null content after extraction', async () => {
mockGetContent.mockImplementation(() => Promise.resolve(null))
const result = await callSummary()
expect((result as any).value).toContain('content is empty')
})
test('handles no messages', async () => {
const result = await callSummary({ ...baseContext, messages: [] })
expect((result as any).value).toBe('No messages to summarize.')
})
})

View File

@@ -1,3 +0,0 @@
import type { Command } from '../../types/command.js'
declare const _default: Command
export default _default

View File

@@ -1 +0,0 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };

View File

@@ -0,0 +1,78 @@
/**
* /summary — Generate and display a session summary.
*
* Triggers a manual Session Memory extraction (bypassing automatic thresholds),
* then reads and displays the updated summary.md file.
*/
import type { Command, LocalCommandCall } from '../../types/command.js'
import type { Message } from '../../types/message.js'
/** Only user/assistant/system messages are valid for API calls. */
const API_SAFE_TYPES = new Set(['user', 'assistant', 'system'])
const call: LocalCommandCall = async (_args, context) => {
const { messages } = context
// Filter to API-safe message types only.
// context.messages includes progress/attachment/etc. that crash the API
// call chain (normalizeMessagesForAPI → addCacheBreakpoints expects
// only user/assistant). The automatic extraction path uses
// createCacheSafeParams(REPLHookContext) which already has clean
// messages; the manual path via /summary does not.
const safeMessages = (messages ?? []).filter(
(m): m is Message => m != null && API_SAFE_TYPES.has(m.type),
)
if (safeMessages.length === 0) {
return { type: 'text', value: 'No messages to summarize.' }
}
try {
const { manuallyExtractSessionMemory } = await import(
'../../services/SessionMemory/sessionMemory.js'
)
const { getSessionMemoryContent } = await import(
'../../services/SessionMemory/sessionMemoryUtils.js'
)
const safeContext = { ...context, messages: safeMessages }
const result = await manuallyExtractSessionMemory(safeMessages, safeContext)
if (!result.success) {
return {
type: 'text',
value: `Failed to generate session summary: ${result.error ?? 'unknown error'}`,
}
}
const content = await getSessionMemoryContent()
if (!content || content.trim().length === 0) {
return {
type: 'text',
value: 'Session summary was updated, but the content is empty.',
}
}
return {
type: 'text',
value: `Session summary updated.\n\n${content}`,
}
} catch (error) {
return {
type: 'text',
value: `Failed to generate session summary: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
const summary = {
type: 'local',
name: 'summary',
description: 'Generate and display a session summary',
supportsNonInteractive: true,
isHidden: false,
load: () => Promise.resolve({ call }),
} satisfies Command
export default summary

View File

@@ -65,7 +65,7 @@ export function isUltraplanEnabled(): boolean {
// load: the GrowthBook cache is empty at import and `/config` Gates can flip
// it between invocations.
function getUltraplanModel(): string {
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus46.firstParty);
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty);
}
// prompt.txt is wrapped in <system-reminder> so the CCR browser hides