mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
Merge remote-tracking branch 'origin/main' into feature/pokemon/battle
This commit is contained in:
@@ -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:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
208
src/commands/autonomyPanel.tsx
Normal file
208
src/commands/autonomyPanel.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(', ')}...`,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
152
src/commands/skill-learning/__tests__/skill-learning.test.ts
Normal file
152
src/commands/skill-learning/__tests__/skill-learning.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
15
src/commands/skill-learning/index.ts
Normal file
15
src/commands/skill-learning/index.ts
Normal 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
|
||||
310
src/commands/skill-learning/skill-learning.ts
Normal file
310
src/commands/skill-learning/skill-learning.ts
Normal 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
|
||||
}
|
||||
197
src/commands/skill-learning/skillPanel.tsx
Normal file
197
src/commands/skill-learning/skillPanel.tsx
Normal 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} />;
|
||||
}
|
||||
12
src/commands/skill-search/index.ts
Normal file
12
src/commands/skill-search/index.ts
Normal 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
|
||||
169
src/commands/skill-search/skillSearchPanel.tsx
Normal file
169
src/commands/skill-search/skillSearchPanel.tsx
Normal 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} />;
|
||||
}
|
||||
91
src/commands/summary/__tests__/summary.test.ts
Normal file
91
src/commands/summary/__tests__/summary.test.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
3
src/commands/summary/index.d.ts
vendored
3
src/commands/summary/index.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
78
src/commands/summary/index.ts
Normal file
78
src/commands/summary/index.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user